react-native-video-trim 7.1.1 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +257 -1
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +488 -34
- package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +95 -36
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +38 -16
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +92 -5
- package/android/src/main/res/drawable/speaker_slash_fill.xml +19 -0
- package/android/src/main/res/drawable/speaker_wave_2_fill.xml +23 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +25 -3
- package/android/src/newarch/VideoTrimModule.kt +33 -0
- package/android/src/oldarch/VideoTrimModule.kt +41 -0
- package/android/src/oldarch/VideoTrimSpec.kt +17 -0
- package/ios/VideoTrim.mm +160 -1
- package/ios/VideoTrim.swift +632 -39
- package/ios/VideoTrimmerViewController.swift +129 -28
- package/lib/module/NativeVideoTrim.js +52 -0
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +143 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +161 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +62 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +183 -0
- package/src/index.tsx +186 -0
|
@@ -25,23 +25,39 @@ object StorageUtil {
|
|
|
25
25
|
return file.absolutePath
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Headless API outputs (compress, toGif, merge, extractAudio, getFrameAt) go to the
|
|
29
|
+
// cache directory. The OS may purge these files under storage pressure when the app is
|
|
30
|
+
// not running, which avoids unbounded storage growth from repeated headless operations.
|
|
31
|
+
fun getCacheOutputPath(context: Context, outputExt: String): String {
|
|
32
|
+
val timestamp = System.currentTimeMillis() / 1000
|
|
33
|
+
val file = File(context.cacheDir, "${VideoTrimmerUtil.FILE_PREFIX}_${timestamp}.$outputExt")
|
|
34
|
+
return file.absolutePath
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
fun isFileExists(filePath: String?): Boolean {
|
|
29
38
|
if (TextUtils.isEmpty(filePath)) return false
|
|
30
39
|
return File(filePath!!).exists()
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
// Scans both the persistent directory (showEditor/trim outputs) and the cache
|
|
43
|
+
// directory (headless API outputs) for files matching our prefix.
|
|
33
44
|
fun listFiles(context: Context): Array<String> {
|
|
34
|
-
val
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
val dirs = listOf(context.filesDir, context.cacheDir)
|
|
46
|
+
return dirs.flatMap { dir ->
|
|
47
|
+
dir.listFiles { _, name -> name.startsWith(VideoTrimmerUtil.FILE_PREFIX) }?.toList() ?: emptyList()
|
|
48
|
+
}.map { it.absolutePath }.toTypedArray()
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
// Path-restricted delete: only allows deletion of files inside our filesDir or cacheDir
|
|
52
|
+
// to prevent accidental deletion of arbitrary files on the device.
|
|
40
53
|
fun deleteFile(path: String?): Boolean {
|
|
41
54
|
if (TextUtils.isEmpty(path)) return true
|
|
42
55
|
val file = File(path!!).canonicalFile
|
|
43
|
-
val
|
|
44
|
-
|
|
56
|
+
val allowedDirs = listOf(
|
|
57
|
+
BaseUtils.getContext().filesDir.canonicalFile,
|
|
58
|
+
BaseUtils.getContext().cacheDir.canonicalFile,
|
|
59
|
+
)
|
|
60
|
+
if (allowedDirs.none { file.path.startsWith(it.path) }) return false
|
|
45
61
|
return deleteFile(file)
|
|
46
62
|
}
|
|
47
63
|
|
|
@@ -62,46 +78,89 @@ object StorageUtil {
|
|
|
62
78
|
return file.delete()
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
fun saveVideoToGallery(context: ReactApplicationContext, videoFilePath: String?) {
|
|
67
|
-
val videoFile = File(videoFilePath!!)
|
|
81
|
+
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif")
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
fun isImageFile(file: File): Boolean {
|
|
84
|
+
val ext = file.extension.lowercase()
|
|
85
|
+
return ext in IMAGE_EXTENSIONS
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Dispatches to the correct MediaStore collection (Images vs Video) based on file
|
|
89
|
+
// extension. Using the wrong collection causes the Photos app to misidentify the file.
|
|
90
|
+
@Throws(IOException::class)
|
|
91
|
+
fun saveToGallery(context: ReactApplicationContext, filePath: String?) {
|
|
92
|
+
val file = File(filePath!!)
|
|
93
|
+
if (isImageFile(file)) {
|
|
94
|
+
saveImageToGallery(context, file)
|
|
71
95
|
} else {
|
|
72
|
-
|
|
73
|
-
saveVideoUsingTraditionalStorage(context, videoFile)
|
|
74
|
-
} catch (e: IOException) {
|
|
75
|
-
throw RuntimeException(e)
|
|
76
|
-
}
|
|
96
|
+
saveVideoToGallery(context, file)
|
|
77
97
|
}
|
|
78
98
|
}
|
|
79
99
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
// On Android Q+ (API 29+), uses the IS_PENDING pattern: the MediaStore entry is
|
|
101
|
+
// created as pending (invisible to other apps), the file is copied, then IS_PENDING
|
|
102
|
+
// is cleared to make it visible. This prevents partial files from appearing in the
|
|
103
|
+
// gallery during the write.
|
|
104
|
+
@Throws(IOException::class)
|
|
105
|
+
private fun saveVideoToGallery(context: ReactApplicationContext, videoFile: File) {
|
|
106
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
107
|
+
val values = ContentValues().apply {
|
|
108
|
+
put(MediaStore.Video.Media.DISPLAY_NAME, videoFile.name)
|
|
109
|
+
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
|
110
|
+
put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
|
|
111
|
+
put(MediaStore.Video.Media.IS_PENDING, 1)
|
|
112
|
+
}
|
|
113
|
+
val resolver = context.contentResolver
|
|
114
|
+
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
|
|
115
|
+
if (uri != null) {
|
|
116
|
+
resolver.openOutputStream(uri)?.use { os -> copyFile(videoFile, os) }
|
|
117
|
+
values.clear()
|
|
118
|
+
values.put(MediaStore.Video.Media.IS_PENDING, 0)
|
|
119
|
+
resolver.update(uri, values, null, null)
|
|
95
120
|
}
|
|
121
|
+
} else {
|
|
122
|
+
val galleryDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
|
|
123
|
+
val dest = File(galleryDir, videoFile.name)
|
|
124
|
+
copyFile(videoFile, dest)
|
|
125
|
+
MediaScannerConnection.scanFile(context, arrayOf(dest.absolutePath), arrayOf("video/*"), null)
|
|
96
126
|
}
|
|
97
127
|
}
|
|
98
128
|
|
|
129
|
+
// Same IS_PENDING pattern as saveVideoToGallery but targets MediaStore.Images.Media
|
|
130
|
+
// and Environment.DIRECTORY_PICTURES. MIME type is inferred from the file extension.
|
|
99
131
|
@Throws(IOException::class)
|
|
100
|
-
private fun
|
|
101
|
-
val
|
|
102
|
-
val
|
|
103
|
-
|
|
104
|
-
|
|
132
|
+
private fun saveImageToGallery(context: ReactApplicationContext, imageFile: File) {
|
|
133
|
+
val ext = imageFile.extension.lowercase()
|
|
134
|
+
val mimeType = when (ext) {
|
|
135
|
+
"png" -> "image/png"
|
|
136
|
+
"gif" -> "image/gif"
|
|
137
|
+
"webp" -> "image/webp"
|
|
138
|
+
"bmp" -> "image/bmp"
|
|
139
|
+
"heic", "heif" -> "image/heic"
|
|
140
|
+
else -> "image/jpeg"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
144
|
+
val values = ContentValues().apply {
|
|
145
|
+
put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.name)
|
|
146
|
+
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
|
|
147
|
+
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
|
|
148
|
+
put(MediaStore.Images.Media.IS_PENDING, 1)
|
|
149
|
+
}
|
|
150
|
+
val resolver = context.contentResolver
|
|
151
|
+
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
|
152
|
+
if (uri != null) {
|
|
153
|
+
resolver.openOutputStream(uri)?.use { os -> copyFile(imageFile, os) }
|
|
154
|
+
values.clear()
|
|
155
|
+
values.put(MediaStore.Images.Media.IS_PENDING, 0)
|
|
156
|
+
resolver.update(uri, values, null, null)
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
|
160
|
+
val dest = File(picturesDir, imageFile.name)
|
|
161
|
+
copyFile(imageFile, dest)
|
|
162
|
+
MediaScannerConnection.scanFile(context, arrayOf(dest.absolutePath), arrayOf(mimeType), null)
|
|
163
|
+
}
|
|
105
164
|
}
|
|
106
165
|
|
|
107
166
|
@Throws(IOException::class)
|
|
@@ -38,6 +38,21 @@ object VideoTrimmerUtil {
|
|
|
38
38
|
@JvmField val THUMB_WIDTH = UnitConverter.dpToPx(25)
|
|
39
39
|
private const val THUMB_RESOLUTION_RES = 2
|
|
40
40
|
|
|
41
|
+
internal fun buildAtempoChain(speed: Double): String {
|
|
42
|
+
var remaining = speed
|
|
43
|
+
val filters = mutableListOf<String>()
|
|
44
|
+
while (remaining < 0.5) {
|
|
45
|
+
filters.add("atempo=0.5")
|
|
46
|
+
remaining /= 0.5
|
|
47
|
+
}
|
|
48
|
+
while (remaining > 2.0) {
|
|
49
|
+
filters.add("atempo=2.0")
|
|
50
|
+
remaining /= 2.0
|
|
51
|
+
}
|
|
52
|
+
filters.add("atempo=$remaining")
|
|
53
|
+
return filters.joinToString(",")
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
fun trim(
|
|
42
57
|
inputFile: String,
|
|
43
58
|
outputFile: String,
|
|
@@ -51,6 +66,8 @@ object VideoTrimmerUtil {
|
|
|
51
66
|
videoHeight: Int,
|
|
52
67
|
videoBitrate: Long,
|
|
53
68
|
enablePreciseTrimming: Boolean,
|
|
69
|
+
removeAudio: Boolean,
|
|
70
|
+
speed: Double,
|
|
54
71
|
callback: VideoTrimListener
|
|
55
72
|
): FFmpegSession {
|
|
56
73
|
val currentDate = Date()
|
|
@@ -67,9 +84,9 @@ object VideoTrimmerUtil {
|
|
|
67
84
|
|
|
68
85
|
val hasUserTransform = userRotationCount != 0 || userIsFlipped
|
|
69
86
|
// Re-encode is required when: (1) user applied flip/rotate, (2) user cropped, or
|
|
70
|
-
// (3) enablePreciseTrimming is on. In
|
|
71
|
-
// either we need video filters or we need frame-accurate cut points.
|
|
72
|
-
val needsReEncode = hasUserTransform || cropNormalized != null || enablePreciseTrimming
|
|
87
|
+
// (3) enablePreciseTrimming is on, or (4) speed != 1.0. In those cases, -c copy
|
|
88
|
+
// won't work because either we need video filters or we need frame-accurate cut points.
|
|
89
|
+
val needsReEncode = hasUserTransform || cropNormalized != null || enablePreciseTrimming || speed != 1.0
|
|
73
90
|
|
|
74
91
|
if (needsReEncode) {
|
|
75
92
|
val videoFilters = mutableListOf<String>()
|
|
@@ -105,6 +122,10 @@ object VideoTrimmerUtil {
|
|
|
105
122
|
}
|
|
106
123
|
}
|
|
107
124
|
|
|
125
|
+
if (speed != 1.0) {
|
|
126
|
+
videoFilters.add("setpts=${1.0 / speed}*PTS")
|
|
127
|
+
}
|
|
128
|
+
|
|
108
129
|
val filterString = videoFilters.joinToString(",")
|
|
109
130
|
// Preserve source quality by matching the original bitrate. Falls back to 10 Mbps.
|
|
110
131
|
val bitrateStr = if (videoBitrate > 0) "$videoBitrate" else "10M"
|
|
@@ -118,21 +139,22 @@ object VideoTrimmerUtil {
|
|
|
118
139
|
// h264_mediacodec: Android's hardware H.264 encoder — fast and energy-efficient.
|
|
119
140
|
// Note: Android FFmpegKit auto-rotates by default, so no -noautorotate is needed.
|
|
120
141
|
// The transpose filters above only handle user-initiated rotation, not source metadata.
|
|
121
|
-
cmds.addAll(listOf(
|
|
122
|
-
|
|
123
|
-
"-
|
|
124
|
-
"-
|
|
125
|
-
"-
|
|
126
|
-
|
|
127
|
-
))
|
|
142
|
+
cmds.addAll(listOf("-c:v", "h264_mediacodec", "-b:v", bitrateStr))
|
|
143
|
+
when {
|
|
144
|
+
removeAudio -> cmds.add("-an")
|
|
145
|
+
speed != 1.0 -> cmds.addAll(listOf("-af", buildAtempoChain(speed)))
|
|
146
|
+
else -> cmds.addAll(listOf("-c:a", "copy"))
|
|
147
|
+
}
|
|
148
|
+
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
|
|
128
149
|
} else {
|
|
129
150
|
// Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
|
|
130
|
-
cmds.addAll(listOf(
|
|
131
|
-
|
|
132
|
-
"-c", "copy",
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
151
|
+
cmds.addAll(listOf("-i", inputFile))
|
|
152
|
+
if (removeAudio) {
|
|
153
|
+
cmds.addAll(listOf("-c:v", "copy", "-an"))
|
|
154
|
+
} else {
|
|
155
|
+
cmds.addAll(listOf("-c", "copy"))
|
|
156
|
+
}
|
|
157
|
+
cmds.addAll(listOf("-metadata", "creation_time=$formattedDateTime", outputFile))
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
val command = cmds.toTypedArray()
|
|
@@ -13,6 +13,7 @@ import android.media.MediaExtractor
|
|
|
13
13
|
import android.media.MediaFormat
|
|
14
14
|
import android.media.MediaMetadataRetriever
|
|
15
15
|
import android.media.MediaPlayer
|
|
16
|
+
import android.media.PlaybackParams
|
|
16
17
|
import android.net.Uri
|
|
17
18
|
import android.os.Build
|
|
18
19
|
import android.os.Handler
|
|
@@ -202,14 +203,23 @@ class VideoTrimmerView(
|
|
|
202
203
|
private lateinit var cropBtn: ImageView
|
|
203
204
|
private lateinit var undoBtn: ImageView
|
|
204
205
|
private lateinit var redoBtn: ImageView
|
|
206
|
+
private lateinit var muteBtn: ImageView
|
|
207
|
+
private lateinit var speedBtn: TextView
|
|
205
208
|
private lateinit var videoContainer: FrameLayout
|
|
206
209
|
private var cropOverlay: CropOverlayView? = null
|
|
207
210
|
|
|
211
|
+
internal var isMuted = false
|
|
212
|
+
private set
|
|
213
|
+
private var configRemoveAudio = false
|
|
214
|
+
private var speed: Double = 1.0
|
|
215
|
+
private val speedOptions = doubleArrayOf(0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0)
|
|
216
|
+
|
|
208
217
|
private lateinit var trimmerView: RelativeLayout
|
|
209
218
|
|
|
210
219
|
private var trimmerColor = context.getString(R.string.trim_color).toColorInt()
|
|
211
220
|
private var handleIconColor = Color.BLACK
|
|
212
221
|
private var isLightTheme = false
|
|
222
|
+
private var durationFormat: String = "mm:ss.SSS"
|
|
213
223
|
private val iconColor: Int get() = if (isLightTheme) Color.BLACK else Color.WHITE
|
|
214
224
|
private val dimmedIconColor: Int get() = if (isLightTheme) Color.argb(128, 0, 0, 0) else Color.argb(128, 255, 255, 255)
|
|
215
225
|
private lateinit var leadingChevron: ImageView
|
|
@@ -283,6 +293,8 @@ class VideoTrimmerView(
|
|
|
283
293
|
flipBtn = findViewById(R.id.flipBtn)
|
|
284
294
|
rotateBtn = findViewById(R.id.rotateBtn)
|
|
285
295
|
cropBtn = findViewById(R.id.cropBtn)
|
|
296
|
+
muteBtn = findViewById(R.id.muteBtn)
|
|
297
|
+
speedBtn = findViewById(R.id.speedBtn)
|
|
286
298
|
undoBtn = findViewById(R.id.undoBtn)
|
|
287
299
|
redoBtn = findViewById(R.id.redoBtn)
|
|
288
300
|
videoContainer = findViewById(R.id.videoContainer)
|
|
@@ -510,6 +522,9 @@ class VideoTrimmerView(
|
|
|
510
522
|
playOrPause()
|
|
511
523
|
}
|
|
512
524
|
|
|
525
|
+
mediaPlayer?.setVolume(if (isMuted) 0f else 1f, if (isMuted) 0f else 1f)
|
|
526
|
+
applyPlaybackSpeed()
|
|
527
|
+
|
|
513
528
|
if (isVideoType) {
|
|
514
529
|
transformRow.alpha = 0f
|
|
515
530
|
transformRow.visibility = View.VISIBLE
|
|
@@ -568,11 +583,56 @@ class VideoTrimmerView(
|
|
|
568
583
|
seekTo(startTime, true)
|
|
569
584
|
}
|
|
570
585
|
player.start()
|
|
586
|
+
applyPlaybackSpeed()
|
|
571
587
|
startTimingRunnable()
|
|
572
588
|
}
|
|
573
589
|
setPlayPauseViewIcon(player.isPlaying)
|
|
574
590
|
}
|
|
575
591
|
|
|
592
|
+
private fun applyPlaybackSpeed() {
|
|
593
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
594
|
+
val player = mediaPlayer ?: return
|
|
595
|
+
val params = player.playbackParams ?: PlaybackParams()
|
|
596
|
+
player.playbackParams = params.setSpeed(speed.toFloat())
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private fun onMuteTapped() {
|
|
601
|
+
isMuted = !isMuted
|
|
602
|
+
muteBtn.setImageResource(if (isMuted) R.drawable.speaker_slash_fill else R.drawable.speaker_wave_2_fill)
|
|
603
|
+
mediaPlayer?.setVolume(if (isMuted) 0f else 1f, if (isMuted) 0f else 1f)
|
|
604
|
+
if (enableHapticFeedback) {
|
|
605
|
+
performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Uses Android's native PopupMenu anchored to the speed button for a platform-
|
|
610
|
+
// consistent speed selector (equivalent to iOS's UIMenu on iOS 14+).
|
|
611
|
+
private fun onSpeedTapped() {
|
|
612
|
+
val popup = android.widget.PopupMenu(context, speedBtn)
|
|
613
|
+
speedOptions.forEachIndexed { index, opt ->
|
|
614
|
+
val title = if (opt == 1.0) "Normal (1x)" else "${opt}x"
|
|
615
|
+
popup.menu.add(0, index, index, title)
|
|
616
|
+
}
|
|
617
|
+
popup.setOnMenuItemClickListener { item ->
|
|
618
|
+
setSpeed(speedOptions[item.itemId])
|
|
619
|
+
true
|
|
620
|
+
}
|
|
621
|
+
popup.show()
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private fun setSpeed(newSpeed: Double) {
|
|
625
|
+
speed = newSpeed
|
|
626
|
+
speedBtn.text = if (newSpeed == 1.0) "1x" else "${newSpeed}x"
|
|
627
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
628
|
+
mediaPlayer?.playbackParams = mediaPlayer?.playbackParams?.setSpeed(newSpeed.toFloat())
|
|
629
|
+
?: PlaybackParams().setSpeed(newSpeed.toFloat())
|
|
630
|
+
}
|
|
631
|
+
if (enableHapticFeedback) {
|
|
632
|
+
performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
576
636
|
fun onMediaPause() {
|
|
577
637
|
mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
|
|
578
638
|
val player = mediaPlayer ?: return
|
|
@@ -598,6 +658,8 @@ class VideoTrimmerView(
|
|
|
598
658
|
cropBtn.setOnClickListener { onCropTapped() }
|
|
599
659
|
undoBtn.setOnClickListener { onUndoTapped() }
|
|
600
660
|
redoBtn.setOnClickListener { onRedoTapped() }
|
|
661
|
+
muteBtn.setOnClickListener { onMuteTapped() }
|
|
662
|
+
speedBtn.setOnClickListener { onSpeedTapped() }
|
|
601
663
|
|
|
602
664
|
cropBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
603
665
|
undoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
@@ -614,6 +676,7 @@ class VideoTrimmerView(
|
|
|
614
676
|
?.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
|
615
677
|
?.toLongOrNull() ?: 0L
|
|
616
678
|
}
|
|
679
|
+
val effectiveRemoveAudio = isMuted || configRemoveAudio
|
|
617
680
|
ffmpegSession = VideoTrimmerUtil.trim(
|
|
618
681
|
mSourceUri.toString(),
|
|
619
682
|
StorageUtil.getOutputPath(mContext, mOutputExt),
|
|
@@ -627,6 +690,8 @@ class VideoTrimmerView(
|
|
|
627
690
|
vh,
|
|
628
691
|
bitrate,
|
|
629
692
|
enablePreciseTrimming,
|
|
693
|
+
effectiveRemoveAudio,
|
|
694
|
+
speed,
|
|
630
695
|
mOnTrimVideoListener
|
|
631
696
|
)
|
|
632
697
|
}
|
|
@@ -741,6 +806,7 @@ class VideoTrimmerView(
|
|
|
741
806
|
}
|
|
742
807
|
|
|
743
808
|
isLightTheme = config.hasKey("theme") && config.getString("theme") == "light"
|
|
809
|
+
durationFormat = if (config.hasKey("durationFormat")) config.getString("durationFormat") ?: "mm:ss.SSS" else "mm:ss.SSS"
|
|
744
810
|
|
|
745
811
|
cancelBtn.text = config.getString("cancelButtonText")
|
|
746
812
|
saveBtn.text = config.getString("saveButtonText")
|
|
@@ -755,6 +821,17 @@ class VideoTrimmerView(
|
|
|
755
821
|
enablePreciseTrimming = config.hasKey("enablePreciseTrimming") && config.getBoolean("enablePreciseTrimming")
|
|
756
822
|
autoplay = config.hasKey("autoplay") && config.getBoolean("autoplay")
|
|
757
823
|
|
|
824
|
+
if (config.hasKey("removeAudio")) {
|
|
825
|
+
configRemoveAudio = config.getBoolean("removeAudio")
|
|
826
|
+
isMuted = configRemoveAudio
|
|
827
|
+
}
|
|
828
|
+
if (config.hasKey("speed") && config.getDouble("speed") > 0) {
|
|
829
|
+
speed = config.getDouble("speed")
|
|
830
|
+
speedBtn.text = if (speed == 1.0) "1x" else "${speed}x"
|
|
831
|
+
}
|
|
832
|
+
muteBtn.setImageResource(if (isMuted) R.drawable.speaker_slash_fill else R.drawable.speaker_wave_2_fill)
|
|
833
|
+
muteBtn.visibility = if (isVideoType) View.VISIBLE else View.GONE
|
|
834
|
+
|
|
758
835
|
if (config.hasKey("jumpToPositionOnLoad") && config.getDouble("jumpToPositionOnLoad") > 0) {
|
|
759
836
|
jumpToPositionOnLoad = maxOf(0, (config.getDouble("jumpToPositionOnLoad") * 1000L).toLong())
|
|
760
837
|
}
|
|
@@ -856,6 +933,8 @@ class VideoTrimmerView(
|
|
|
856
933
|
cropBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
857
934
|
undoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
858
935
|
redoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
936
|
+
muteBtn.setColorFilter(iconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
937
|
+
speedBtn.setTextColor(iconColor)
|
|
859
938
|
|
|
860
939
|
// Overlays
|
|
861
940
|
leadingOverlay.setBackgroundColor(overlayColor)
|
|
@@ -936,11 +1015,19 @@ class VideoTrimmerView(
|
|
|
936
1015
|
}
|
|
937
1016
|
|
|
938
1017
|
private fun formatTime(milliseconds: Int): String {
|
|
939
|
-
val
|
|
940
|
-
val
|
|
941
|
-
val
|
|
942
|
-
val
|
|
943
|
-
|
|
1018
|
+
val totalMs = if (milliseconds < 0) 0 else milliseconds
|
|
1019
|
+
val h = totalMs / 3_600_000
|
|
1020
|
+
val m = (totalMs / 60_000) % 60
|
|
1021
|
+
val s = (totalMs / 1000) % 60
|
|
1022
|
+
val ms = totalMs % 1000
|
|
1023
|
+
val locale = Locale.getDefault()
|
|
1024
|
+
return when (durationFormat) {
|
|
1025
|
+
"mm:ss" -> String.format(locale, "%02d:%02d", m + h * 60, s)
|
|
1026
|
+
"mm:ss.SS" -> String.format(locale, "%02d:%02d.%02d", m + h * 60, s, ms / 10)
|
|
1027
|
+
"hh:mm:ss" -> String.format(locale, "%02d:%02d:%02d", h, m, s)
|
|
1028
|
+
"hh:mm:ss.SSS" -> String.format(locale, "%02d:%02d:%02d.%03d", h, m, s, ms)
|
|
1029
|
+
else -> String.format(locale, "%02d:%02d.%03d", m + h * 60, s, ms)
|
|
1030
|
+
}
|
|
944
1031
|
}
|
|
945
1032
|
|
|
946
1033
|
@Suppress("ClickableViewAccessibility")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="20.574dp"
|
|
3
|
+
android:height="21.997dp"
|
|
4
|
+
android:viewportWidth="20.574"
|
|
5
|
+
android:viewportHeight="21.997">
|
|
6
|
+
<path
|
|
7
|
+
android:fillColor="#FF000000"
|
|
8
|
+
android:pathData="M0,0h20.574v21.997h-20.574z"
|
|
9
|
+
android:strokeAlpha="0"
|
|
10
|
+
android:fillAlpha="0"/>
|
|
11
|
+
<path
|
|
12
|
+
android:pathData="M14.746,18.846C14.618,19.308 14.214,19.617 13.685,19.617C13.236,19.617 12.855,19.431 12.406,19.001L8.246,15.056C8.177,14.998 8.099,14.968 8.002,14.968L5.199,14.968C3.881,14.968 3.158,14.246 3.158,12.83L3.158,9.197C3.158,8.561 3.306,8.063 3.59,7.71ZM14.789,3.543L14.789,13.536L8.231,6.982C8.236,6.978 8.241,6.974 8.246,6.97L12.406,3.064C12.904,2.585 13.217,2.39 13.666,2.39C14.33,2.39 14.789,2.908 14.789,3.543Z"
|
|
13
|
+
android:fillColor="#ffffff"
|
|
14
|
+
android:fillAlpha="0.85"/>
|
|
15
|
+
<path
|
|
16
|
+
android:pathData="M18.158,20.642C18.451,20.935 18.929,20.935 19.222,20.642C19.506,20.339 19.515,19.871 19.222,19.578L2.494,2.859C2.201,2.566 1.713,2.566 1.42,2.859C1.136,3.142 1.136,3.64 1.42,3.923Z"
|
|
17
|
+
android:fillColor="#ffffff"
|
|
18
|
+
android:fillAlpha="0.85"/>
|
|
19
|
+
</vector>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="22.158dp"
|
|
3
|
+
android:height="17.236dp"
|
|
4
|
+
android:viewportWidth="22.158"
|
|
5
|
+
android:viewportHeight="17.236">
|
|
6
|
+
<path
|
|
7
|
+
android:fillColor="#FF000000"
|
|
8
|
+
android:pathData="M0,0h22.158v17.236h-22.158z"
|
|
9
|
+
android:strokeAlpha="0"
|
|
10
|
+
android:fillAlpha="0"/>
|
|
11
|
+
<path
|
|
12
|
+
android:pathData="M18.74,15.322C19.102,15.566 19.551,15.479 19.815,15.107C21.065,13.389 21.797,11.025 21.797,8.613C21.797,6.201 21.074,3.818 19.815,2.119C19.551,1.748 19.102,1.66 18.74,1.904C18.379,2.148 18.32,2.607 18.604,3.008C19.668,4.512 20.283,6.533 20.283,8.613C20.283,10.693 19.648,12.695 18.604,14.219C18.33,14.619 18.379,15.078 18.74,15.322Z"
|
|
13
|
+
android:fillColor="#ffffff"
|
|
14
|
+
android:fillAlpha="0.85"/>
|
|
15
|
+
<path
|
|
16
|
+
android:pathData="M15.127,12.773C15.449,12.998 15.908,12.93 16.172,12.549C16.924,11.563 17.373,10.107 17.373,8.613C17.373,7.119 16.924,5.674 16.172,4.678C15.908,4.297 15.449,4.219 15.127,4.453C14.727,4.727 14.668,5.215 14.971,5.615C15.537,6.396 15.859,7.48 15.859,8.613C15.859,9.746 15.527,10.82 14.971,11.611C14.678,12.021 14.727,12.49 15.127,12.773Z"
|
|
17
|
+
android:fillColor="#ffffff"
|
|
18
|
+
android:fillAlpha="0.85"/>
|
|
19
|
+
<path
|
|
20
|
+
android:pathData="M10.527,17.236C11.172,17.236 11.631,16.777 11.631,16.143L11.631,1.162C11.631,0.527 11.172,0.01 10.508,0.01C10.049,0.01 9.746,0.215 9.248,0.684L5.078,4.59C5.02,4.648 4.932,4.678 4.834,4.678L2.041,4.678C0.713,4.678 0,5.41 0,6.816L0,10.449C0,11.865 0.713,12.588 2.041,12.588L4.834,12.588C4.932,12.588 5.02,12.617 5.078,12.676L9.248,16.621C9.688,17.051 10.068,17.236 10.527,17.236Z"
|
|
21
|
+
android:fillColor="#ffffff"
|
|
22
|
+
android:fillAlpha="0.85"/>
|
|
23
|
+
</vector>
|
|
@@ -79,6 +79,28 @@
|
|
|
79
79
|
android:tint="#80FFFFFF"
|
|
80
80
|
android:scaleType="fitCenter"
|
|
81
81
|
android:contentDescription="Crop" />
|
|
82
|
+
|
|
83
|
+
<ImageView
|
|
84
|
+
android:id="@+id/muteBtn"
|
|
85
|
+
android:layout_width="22dp"
|
|
86
|
+
android:layout_height="22dp"
|
|
87
|
+
android:src="@drawable/speaker_wave_2_fill"
|
|
88
|
+
android:tint="@android:color/white"
|
|
89
|
+
android:scaleType="fitCenter"
|
|
90
|
+
android:layout_marginStart="12dp"
|
|
91
|
+
android:contentDescription="Mute" />
|
|
92
|
+
|
|
93
|
+
<TextView
|
|
94
|
+
android:id="@+id/speedBtn"
|
|
95
|
+
android:layout_width="wrap_content"
|
|
96
|
+
android:layout_height="22dp"
|
|
97
|
+
android:text="1x"
|
|
98
|
+
android:textColor="@android:color/white"
|
|
99
|
+
android:textSize="13sp"
|
|
100
|
+
android:textStyle="bold"
|
|
101
|
+
android:gravity="center"
|
|
102
|
+
android:layout_marginStart="12dp"
|
|
103
|
+
android:contentDescription="Speed" />
|
|
82
104
|
</LinearLayout>
|
|
83
105
|
|
|
84
106
|
<View
|
|
@@ -271,7 +293,7 @@
|
|
|
271
293
|
android:focusable="true"
|
|
272
294
|
android:gravity="center"
|
|
273
295
|
android:padding="10dp"
|
|
274
|
-
android:text="00:00
|
|
296
|
+
android:text="00:00"
|
|
275
297
|
android:textColor="@android:color/white"
|
|
276
298
|
android:textSize="16sp" />
|
|
277
299
|
|
|
@@ -284,7 +306,7 @@
|
|
|
284
306
|
android:focusable="true"
|
|
285
307
|
android:gravity="center"
|
|
286
308
|
android:padding="10dp"
|
|
287
|
-
android:text="00:00
|
|
309
|
+
android:text="00:00"
|
|
288
310
|
android:textColor="@android:color/white"
|
|
289
311
|
android:textSize="16sp" />
|
|
290
312
|
|
|
@@ -297,7 +319,7 @@
|
|
|
297
319
|
android:focusable="true"
|
|
298
320
|
android:gravity="center"
|
|
299
321
|
android:padding="10dp"
|
|
300
|
-
android:text="00:00
|
|
322
|
+
android:text="00:00"
|
|
301
323
|
android:textColor="@android:color/white"
|
|
302
324
|
android:textSize="16sp" />
|
|
303
325
|
</FrameLayout>
|
|
@@ -3,6 +3,7 @@ package com.videotrim
|
|
|
3
3
|
import android.util.Log
|
|
4
4
|
import com.facebook.react.bridge.Promise
|
|
5
5
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.bridge.ReadableArray
|
|
6
7
|
import com.facebook.react.bridge.ReadableMap
|
|
7
8
|
import com.facebook.react.bridge.WritableMap
|
|
8
9
|
|
|
@@ -69,6 +70,38 @@ class VideoTrimModule(
|
|
|
69
70
|
base.trim(url, options, promise)
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
override fun getFrameAt(url: String, options: ReadableMap, promise: Promise) {
|
|
74
|
+
base.getFrameAt(url, options, promise)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
override fun extractAudio(url: String, options: ReadableMap, promise: Promise) {
|
|
78
|
+
base.extractAudio(url, options, promise)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override fun compress(url: String, options: ReadableMap, promise: Promise) {
|
|
82
|
+
base.compress(url, options, promise)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
override fun toGif(url: String, options: ReadableMap, promise: Promise) {
|
|
86
|
+
base.toGif(url, options, promise)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override fun merge(urls: ReadableArray, options: ReadableMap, promise: Promise) {
|
|
90
|
+
base.merge(urls, options, promise)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override fun saveToPhoto(filePath: String, promise: Promise) {
|
|
94
|
+
base.saveToPhoto(filePath, promise)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
override fun saveToDocuments(filePath: String, promise: Promise) {
|
|
98
|
+
base.saveToDocuments(filePath, promise)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override fun share(filePath: String, promise: Promise) {
|
|
102
|
+
base.share(filePath, promise)
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
companion object {
|
|
73
106
|
const val NAME = "VideoTrim"
|
|
74
107
|
}
|
|
@@ -3,6 +3,7 @@ package com.videotrim
|
|
|
3
3
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
4
4
|
import com.facebook.react.bridge.Arguments
|
|
5
5
|
import com.facebook.react.bridge.WritableMap
|
|
6
|
+
import com.facebook.react.bridge.ReadableArray
|
|
6
7
|
|
|
7
8
|
import com.facebook.react.bridge.*
|
|
8
9
|
import com.facebook.react.module.annotations.ReactModule
|
|
@@ -69,6 +70,46 @@ class VideoTrimModule internal constructor(context: ReactApplicationContext) : V
|
|
|
69
70
|
base.trim(url, options, promise)
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
@ReactMethod
|
|
74
|
+
override fun getFrameAt(url: String, options: ReadableMap?, promise: Promise) {
|
|
75
|
+
base.getFrameAt(url, options, promise)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@ReactMethod
|
|
79
|
+
override fun extractAudio(url: String, options: ReadableMap?, promise: Promise) {
|
|
80
|
+
base.extractAudio(url, options, promise)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@ReactMethod
|
|
84
|
+
override fun compress(url: String, options: ReadableMap?, promise: Promise) {
|
|
85
|
+
base.compress(url, options, promise)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@ReactMethod
|
|
89
|
+
override fun toGif(url: String, options: ReadableMap?, promise: Promise) {
|
|
90
|
+
base.toGif(url, options, promise)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@ReactMethod
|
|
94
|
+
override fun merge(urls: ReadableArray, options: ReadableMap?, promise: Promise) {
|
|
95
|
+
base.merge(urls, options, promise)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@ReactMethod
|
|
99
|
+
override fun saveToPhoto(filePath: String, promise: Promise) {
|
|
100
|
+
base.saveToPhoto(filePath, promise)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@ReactMethod
|
|
104
|
+
override fun saveToDocuments(filePath: String, promise: Promise) {
|
|
105
|
+
base.saveToDocuments(filePath, promise)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@ReactMethod
|
|
109
|
+
override fun share(filePath: String, promise: Promise) {
|
|
110
|
+
base.share(filePath, promise)
|
|
111
|
+
}
|
|
112
|
+
|
|
72
113
|
companion object {
|
|
73
114
|
const val NAME = "VideoTrim"
|
|
74
115
|
}
|