react-native-video-trim 6.2.2 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -9
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +146 -119
- package/android/src/main/java/com/videotrim/enums/ErrorCode.kt +2 -1
- package/android/src/main/java/com/videotrim/utils/MediaMetadataUtil.kt +6 -2
- package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +5 -1
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +99 -26
- package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +293 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +556 -28
- package/android/src/main/res/drawable/arrow_trianglehead_left_and_right_righttriangle_left_righttriangle_right.xml +19 -0
- package/android/src/main/res/drawable/arrow_uturn_backward.xml +15 -0
- package/android/src/main/res/drawable/arrow_uturn_forward.xml +15 -0
- package/android/src/main/res/drawable/crop.xml +15 -0
- package/android/src/main/res/drawable/rotate_left.xml +19 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +115 -37
- package/android/src/main/res/xml/file_paths.xml +1 -1
- package/ios/CropOverlayView.swift +285 -0
- package/ios/VideoTrim.mm +2 -4
- package/ios/VideoTrim.swift +198 -61
- package/ios/VideoTrimmer.swift +2 -4
- package/ios/VideoTrimmerViewController.swift +478 -56
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +1 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +10 -4
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +10 -4
- package/src/index.tsx +1 -2
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
- [Advanced Features](#advanced-features)
|
|
15
15
|
* [Audio Trimming](#audio-trimming)
|
|
16
16
|
* [Remote Files (HTTPS)](#remote-files-https)
|
|
17
|
-
* [Video
|
|
17
|
+
* [Video Transforms (Flip, Rotate, Crop)](#video-transforms-flip-rotate-crop)
|
|
18
|
+
* [Precise Frame Trimming](#precise-frame-trimming)
|
|
18
19
|
- [Examples](#examples)
|
|
19
20
|
- [Troubleshooting](#troubleshooting)
|
|
20
21
|
|
|
@@ -40,6 +41,8 @@ A powerful, easy-to-use video and audio trimming library for React Native applic
|
|
|
40
41
|
### ✨ Key Features
|
|
41
42
|
|
|
42
43
|
- **📹 Video & Audio Support** - Trim both video and audio files
|
|
44
|
+
- **🔄 Flip, Rotate & Crop** - Built-in video transforms with undo/redo support
|
|
45
|
+
- **🎯 Precise Trimming** - Optional frame-accurate cuts via hardware-accelerated re-encoding
|
|
43
46
|
- **🌐 Local & Remote Files** - Support for local storage and HTTPS URLs
|
|
44
47
|
- **💾 Multiple Save Options** - Photos, Documents, or Share to other apps
|
|
45
48
|
- **✅ File Validation** - Built-in validation for media files
|
|
@@ -50,7 +53,9 @@ A powerful, easy-to-use video and audio trimming library for React Native applic
|
|
|
50
53
|
|
|
51
54
|
| Feature | Description |
|
|
52
55
|
|---------|-------------|
|
|
53
|
-
| **Trimming** |
|
|
56
|
+
| **Trimming** | Video/audio trimming with visual timeline controls |
|
|
57
|
+
| **Transforms** | Horizontal flip, 90° rotation, and freeform crop with undo/redo |
|
|
58
|
+
| **Precise Trimming** | Frame-accurate cuts using hardware re-encoding (opt-in) |
|
|
54
59
|
| **Validation** | Check if files are valid video/audio before processing |
|
|
55
60
|
| **Save Options** | Photos, Documents, Share sheet integration |
|
|
56
61
|
| **File Management** | Complete file lifecycle management |
|
|
@@ -344,8 +349,7 @@ All configuration options are optional. Here are the most commonly used ones:
|
|
|
344
349
|
|--------|------|---------|-------------|
|
|
345
350
|
| `enableHapticFeedback` | `boolean` | `true` | Enable haptic feedback |
|
|
346
351
|
| `closeWhenFinish` | `boolean` | `true` | Close editor when done |
|
|
347
|
-
| `
|
|
348
|
-
| `rotationAngle` | `number` | `0` | Rotation angle in degrees |
|
|
352
|
+
| `enablePreciseTrimming` | `boolean` | `false` | Re-encode for frame-accurate cuts (slower, see [Precise Frame Trimming](#precise-frame-trimming)) |
|
|
349
353
|
| `changeStatusBarColorOnOpen` | `boolean` | `false` | Change status bar color (Android only) |
|
|
350
354
|
| `zoomOnWaitingDuration` | `number` | `5000` | Duration for zoom-on-waiting feature in milliseconds (default: 5000) |
|
|
351
355
|
|
|
@@ -438,18 +442,39 @@ showEditor('https://example.com/video.mp4', {
|
|
|
438
442
|
});
|
|
439
443
|
```
|
|
440
444
|
|
|
441
|
-
### Video
|
|
445
|
+
### Video Transforms (Flip, Rotate, Crop)
|
|
442
446
|
|
|
443
|
-
|
|
447
|
+
The editor includes built-in transform controls — horizontal flip, 90° left rotation, and freeform crop — with full undo/redo support. These appear as toolbar buttons in the editor UI on both iOS and Android.
|
|
448
|
+
|
|
449
|
+
When any transform is applied, FFmpeg automatically re-encodes the video using the platform's hardware encoder (`h264_videotoolbox` on iOS, `h264_mediacodec` on Android) at the source bitrate to preserve quality. No additional configuration is needed.
|
|
450
|
+
|
|
451
|
+
### Precise Frame Trimming
|
|
452
|
+
|
|
453
|
+
By default, trimming uses FFmpeg's stream copy (`-c copy`), which is very fast but can only cut at keyframes. The actual start/end points may drift by several seconds from what the user selected.
|
|
454
|
+
|
|
455
|
+
Enable `enablePreciseTrimming` for frame-accurate cuts:
|
|
444
456
|
|
|
445
457
|
```javascript
|
|
458
|
+
// Editor mode
|
|
446
459
|
showEditor(videoUrl, {
|
|
447
|
-
|
|
448
|
-
|
|
460
|
+
enablePreciseTrimming: true,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Headless mode
|
|
464
|
+
const result = await trim(videoUrl, {
|
|
465
|
+
startTime: 5000,
|
|
466
|
+
endTime: 15000,
|
|
467
|
+
enablePreciseTrimming: true,
|
|
449
468
|
});
|
|
450
469
|
```
|
|
451
470
|
|
|
452
|
-
|
|
471
|
+
| | `enablePreciseTrimming: false` (default) | `enablePreciseTrimming: true` |
|
|
472
|
+
|---|---|---|
|
|
473
|
+
| **Speed** | Very fast (stream copy) | Slower (hardware re-encode) |
|
|
474
|
+
| **Accuracy** | Keyframe-aligned (may drift 1-5s) | Frame-accurate |
|
|
475
|
+
| **Quality** | Lossless (original bitstream) | Near-lossless (matched bitrate) |
|
|
476
|
+
|
|
477
|
+
**Note:** When transforms (flip/rotate/crop) are applied, re-encoding already happens regardless of this flag, so precise trimming comes for free in that case.
|
|
453
478
|
|
|
454
479
|
### Trimming Progress & Cancellation
|
|
455
480
|
|
|
@@ -11,6 +11,8 @@ import android.content.Intent
|
|
|
11
11
|
import android.content.pm.PackageManager
|
|
12
12
|
import android.content.res.ColorStateList
|
|
13
13
|
import android.graphics.Color
|
|
14
|
+
import android.media.MediaMetadataRetriever
|
|
15
|
+
import android.net.Uri
|
|
14
16
|
import android.os.Build
|
|
15
17
|
import android.util.Log
|
|
16
18
|
import android.util.TypedValue
|
|
@@ -75,6 +77,8 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
75
77
|
get() = editorConfig?.hasKey("changeStatusBarColorOnOpen") == true && editorConfig?.getBoolean("changeStatusBarColorOnOpen") == true
|
|
76
78
|
|
|
77
79
|
init {
|
|
80
|
+
reactApplicationContext.addLifecycleEventListener(this)
|
|
81
|
+
|
|
78
82
|
val mActivityEventListener = object : BaseActivityEventListener() {
|
|
79
83
|
override fun onActivityResult(
|
|
80
84
|
activity: Activity,
|
|
@@ -137,7 +141,10 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
137
141
|
|
|
138
142
|
this.isVideoType = config.hasKey("type") && config.getString("type") == "video"
|
|
139
143
|
|
|
140
|
-
val activity = reactApplicationContext.currentActivity
|
|
144
|
+
val activity = reactApplicationContext.currentActivity ?: run {
|
|
145
|
+
onError("Activity is not available", ErrorCode.UNKNOWN)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
141
148
|
if (!isInit) {
|
|
142
149
|
init()
|
|
143
150
|
isInit = true
|
|
@@ -150,7 +157,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
150
157
|
trimmerView?.initByURI(filePath.toUri())
|
|
151
158
|
|
|
152
159
|
val builder = AlertDialog.Builder(
|
|
153
|
-
activity
|
|
160
|
+
activity, Theme_Black_NoTitleBar_Fullscreen
|
|
154
161
|
)
|
|
155
162
|
builder.setCancelable(false)
|
|
156
163
|
alertDialog = builder.create()
|
|
@@ -158,18 +165,17 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
158
165
|
|
|
159
166
|
// Apply safe area handling after the dialog is shown
|
|
160
167
|
alertDialog?.setOnShowListener {
|
|
161
|
-
|
|
168
|
+
val dialog = alertDialog ?: return@setOnShowListener
|
|
169
|
+
val view = trimmerView ?: return@setOnShowListener
|
|
170
|
+
applySafeAreaToDialog(dialog, view)
|
|
162
171
|
|
|
163
172
|
sendEvent("onShow", null)
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
// this is to ensure to release resource if dialog is dismissed in unexpected way (Eg. open control/notification center by dragging from top of screen)
|
|
167
|
-
alertDialog
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
trimmerView!!.onDestroy()
|
|
171
|
-
trimmerView = null
|
|
172
|
-
}
|
|
176
|
+
alertDialog?.setOnDismissListener {
|
|
177
|
+
trimmerView?.onDestroy()
|
|
178
|
+
trimmerView = null
|
|
173
179
|
hideDialog(true)
|
|
174
180
|
sendEvent("onHide", null)
|
|
175
181
|
}
|
|
@@ -195,7 +201,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
195
201
|
it.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
|
|
196
202
|
|
|
197
203
|
// finally change the color
|
|
198
|
-
it.statusBarColor = ContextCompat.getColor(reactApplicationContext
|
|
204
|
+
it.statusBarColor = ContextCompat.getColor(reactApplicationContext, R.color.black)
|
|
199
205
|
}
|
|
200
206
|
}
|
|
201
207
|
|
|
@@ -251,9 +257,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
251
257
|
|
|
252
258
|
override fun onHostPause() {
|
|
253
259
|
Log.d(TAG, "onHostPause: ")
|
|
254
|
-
|
|
255
|
-
trimmerView!!.onMediaPause()
|
|
256
|
-
}
|
|
260
|
+
trimmerView?.onMediaPause()
|
|
257
261
|
}
|
|
258
262
|
|
|
259
263
|
override fun onHostDestroy() {
|
|
@@ -277,7 +281,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
277
281
|
return
|
|
278
282
|
}
|
|
279
283
|
|
|
280
|
-
mProgressBar
|
|
284
|
+
mProgressBar?.setProgress(percentage, true)
|
|
281
285
|
}
|
|
282
286
|
|
|
283
287
|
|
|
@@ -312,10 +316,11 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
312
316
|
hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
|
|
313
317
|
}
|
|
314
318
|
} else if (editorConfig?.getBoolean("openDocumentsOnFinish") == true) {
|
|
315
|
-
|
|
319
|
+
val output = outputFile ?: return
|
|
320
|
+
saveFileToExternalStorage(File(output))
|
|
316
321
|
} else if (editorConfig?.getBoolean("openShareSheetOnFinish") == true) {
|
|
317
322
|
hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
|
|
318
|
-
shareFile(reactApplicationContext, File(
|
|
323
|
+
outputFile?.let { shareFile(reactApplicationContext, File(it)) }
|
|
319
324
|
} else {
|
|
320
325
|
hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
|
|
321
326
|
}
|
|
@@ -333,13 +338,14 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
333
338
|
}
|
|
334
339
|
|
|
335
340
|
override fun onCancel() {
|
|
336
|
-
if (
|
|
341
|
+
if (editorConfig?.getBoolean("enableCancelDialog") != true) {
|
|
337
342
|
sendEvent("onCancel", null)
|
|
338
343
|
hideDialog(true)
|
|
339
344
|
return
|
|
340
345
|
}
|
|
341
346
|
|
|
342
|
-
val
|
|
347
|
+
val activity = reactApplicationContext.currentActivity ?: return
|
|
348
|
+
val builder = AlertDialog.Builder(activity)
|
|
343
349
|
builder.setMessage(editorConfig?.getString("cancelDialogMessage"))
|
|
344
350
|
builder.setTitle(editorConfig?.getString("cancelDialogTitle"))
|
|
345
351
|
builder.setCancelable(false)
|
|
@@ -358,12 +364,13 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
override fun onSave() {
|
|
361
|
-
if (
|
|
367
|
+
if (editorConfig?.getBoolean("enableSaveDialog") != true) {
|
|
362
368
|
startTrim()
|
|
363
369
|
return
|
|
364
370
|
}
|
|
365
371
|
|
|
366
|
-
val
|
|
372
|
+
val activity = reactApplicationContext.currentActivity ?: return
|
|
373
|
+
val builder = AlertDialog.Builder(activity)
|
|
367
374
|
builder.setMessage(editorConfig?.getString("saveDialogMessage"))
|
|
368
375
|
builder.setTitle(editorConfig?.getString("saveDialogTitle"))
|
|
369
376
|
builder.setCancelable(false)
|
|
@@ -389,7 +396,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
389
396
|
}
|
|
390
397
|
|
|
391
398
|
private fun startTrim() {
|
|
392
|
-
val activity = reactApplicationContext.currentActivity
|
|
399
|
+
val activity = reactApplicationContext.currentActivity ?: return
|
|
393
400
|
// Create the parent layout for the dialog
|
|
394
401
|
val layout = LinearLayout(activity)
|
|
395
402
|
layout.layoutParams = ViewGroup.LayoutParams(
|
|
@@ -413,12 +420,13 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
413
420
|
layout.addView(textView)
|
|
414
421
|
|
|
415
422
|
// Create and add the ProgressBar
|
|
416
|
-
mProgressBar = ProgressBar(activity, null, progressBarStyleHorizontal)
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
423
|
+
mProgressBar = ProgressBar(activity, null, progressBarStyleHorizontal).also {
|
|
424
|
+
it.layoutParams = ViewGroup.LayoutParams(
|
|
425
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
426
|
+
ViewGroup.LayoutParams.WRAP_CONTENT
|
|
427
|
+
)
|
|
428
|
+
it.progressTintList = ColorStateList.valueOf("#2196F3".toColorInt())
|
|
429
|
+
}
|
|
422
430
|
layout.addView(mProgressBar)
|
|
423
431
|
|
|
424
432
|
// Create button
|
|
@@ -433,10 +441,10 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
433
441
|
?: "Cancel Trimming"
|
|
434
442
|
button.setTextColor(
|
|
435
443
|
ContextCompat.getColor(
|
|
436
|
-
activity
|
|
444
|
+
activity,
|
|
437
445
|
holo_red_light
|
|
438
446
|
)
|
|
439
|
-
)
|
|
447
|
+
)
|
|
440
448
|
|
|
441
449
|
// Apply ripple effect while keeping the button background transparent
|
|
442
450
|
val outValue = TypedValue()
|
|
@@ -451,11 +459,9 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
451
459
|
builder.setTitle(editorConfig?.getString("cancelTrimmingDialogTitle"))
|
|
452
460
|
builder.setCancelable(false)
|
|
453
461
|
builder.setPositiveButton(editorConfig?.getString("cancelTrimmingDialogConfirmText")) { _: DialogInterface?, _: Int ->
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if (mProgressDialog != null && mProgressDialog!!.isShowing) {
|
|
458
|
-
mProgressDialog!!.dismiss()
|
|
462
|
+
trimmerView?.onCancelTrimClicked()
|
|
463
|
+
if (mProgressDialog?.isShowing == true) {
|
|
464
|
+
mProgressDialog?.dismiss()
|
|
459
465
|
}
|
|
460
466
|
}
|
|
461
467
|
builder.setNegativeButton(
|
|
@@ -464,14 +470,12 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
464
470
|
dialog.cancel()
|
|
465
471
|
}
|
|
466
472
|
cancelTrimmingConfirmDialog = builder.create()
|
|
467
|
-
cancelTrimmingConfirmDialog
|
|
473
|
+
cancelTrimmingConfirmDialog?.show()
|
|
468
474
|
} else {
|
|
469
|
-
|
|
470
|
-
trimmerView!!.onCancelTrimClicked()
|
|
471
|
-
}
|
|
475
|
+
trimmerView?.onCancelTrimClicked()
|
|
472
476
|
|
|
473
|
-
if (mProgressDialog
|
|
474
|
-
mProgressDialog
|
|
477
|
+
if (mProgressDialog?.isShowing == true) {
|
|
478
|
+
mProgressDialog?.dismiss()
|
|
475
479
|
}
|
|
476
480
|
}
|
|
477
481
|
}
|
|
@@ -480,7 +484,7 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
480
484
|
|
|
481
485
|
// Create the AlertDialog
|
|
482
486
|
val builder = AlertDialog.Builder(
|
|
483
|
-
activity
|
|
487
|
+
activity
|
|
484
488
|
)
|
|
485
489
|
builder.setCancelable(false)
|
|
486
490
|
builder.setView(layout)
|
|
@@ -488,36 +492,29 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
488
492
|
// Show the dialog
|
|
489
493
|
mProgressDialog = builder.create()
|
|
490
494
|
|
|
491
|
-
mProgressDialog
|
|
495
|
+
mProgressDialog?.setOnShowListener {
|
|
492
496
|
sendEvent("onStartTrimming", null)
|
|
493
|
-
|
|
494
|
-
trimmerView!!.onSaveClicked()
|
|
495
|
-
}
|
|
497
|
+
trimmerView?.onSaveClicked()
|
|
496
498
|
}
|
|
497
499
|
|
|
498
|
-
mProgressDialog
|
|
500
|
+
mProgressDialog?.show()
|
|
499
501
|
}
|
|
500
502
|
|
|
501
503
|
private fun hideDialog(shouldCloseEditor: Boolean) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (cancelTrimmingConfirmDialog!!.isShowing) {
|
|
505
|
-
cancelTrimmingConfirmDialog!!.dismiss()
|
|
506
|
-
}
|
|
504
|
+
cancelTrimmingConfirmDialog?.let {
|
|
505
|
+
if (it.isShowing) it.dismiss()
|
|
507
506
|
cancelTrimmingConfirmDialog = null
|
|
508
507
|
}
|
|
509
508
|
|
|
510
|
-
|
|
511
|
-
if (
|
|
509
|
+
mProgressDialog?.let {
|
|
510
|
+
if (it.isShowing) it.dismiss()
|
|
512
511
|
mProgressBar = null
|
|
513
512
|
mProgressDialog = null
|
|
514
513
|
}
|
|
515
514
|
|
|
516
515
|
if (shouldCloseEditor) {
|
|
517
|
-
|
|
518
|
-
if (
|
|
519
|
-
alertDialog!!.dismiss()
|
|
520
|
-
}
|
|
516
|
+
alertDialog?.let {
|
|
517
|
+
if (it.isShowing) it.dismiss()
|
|
521
518
|
alertDialog = null
|
|
522
519
|
}
|
|
523
520
|
}
|
|
@@ -550,7 +547,6 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
550
547
|
|
|
551
548
|
fun closeEditor() {
|
|
552
549
|
hideDialog(true)
|
|
553
|
-
sendEvent("onHide", null)
|
|
554
550
|
}
|
|
555
551
|
|
|
556
552
|
fun isValidFile(url: String, promise: Promise) {
|
|
@@ -587,82 +583,109 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
587
583
|
"${endTime}ms",
|
|
588
584
|
)
|
|
589
585
|
|
|
590
|
-
|
|
591
|
-
|
|
586
|
+
val outputFile = StorageUtil.getOutputPath(reactApplicationContext, options?.getString("outputExt") ?: "mp4")
|
|
587
|
+
|
|
588
|
+
val resolvedOutputFile = outputFile ?: run {
|
|
589
|
+
promise.reject(Exception("Failed to create output file path"))
|
|
590
|
+
return
|
|
592
591
|
}
|
|
593
592
|
|
|
594
|
-
|
|
593
|
+
// Headless trim: no editor UI, so no transforms (flip/rotate/crop) are possible.
|
|
594
|
+
// The only reason to re-encode here is enablePreciseTrimming for frame-accurate cuts.
|
|
595
|
+
val enablePrecise = options?.hasKey("enablePreciseTrimming") == true &&
|
|
596
|
+
options.getBoolean("enablePreciseTrimming")
|
|
595
597
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
598
|
+
if (enablePrecise) {
|
|
599
|
+
// Match source bitrate to preserve quality; fall back to 10 Mbps.
|
|
600
|
+
var bitrateStr = "10M"
|
|
601
|
+
try {
|
|
602
|
+
val retriever = MediaMetadataRetriever()
|
|
603
|
+
retriever.setDataSource(reactApplicationContext, Uri.parse(url))
|
|
604
|
+
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
|
605
|
+
?.toLongOrNull() ?: 0L
|
|
606
|
+
if (bitrate > 0) bitrateStr = "$bitrate"
|
|
607
|
+
retriever.release()
|
|
608
|
+
} catch (_: Exception) {}
|
|
609
|
+
|
|
610
|
+
// h264_mediacodec: Android's hardware H.264 encoder.
|
|
611
|
+
// No -noautorotate needed — FFmpegKit on Android auto-rotates correctly.
|
|
612
|
+
cmds += arrayOf(
|
|
613
|
+
"-i", url,
|
|
614
|
+
"-c:v", "h264_mediacodec",
|
|
615
|
+
"-b:v", bitrateStr,
|
|
616
|
+
"-c:a", "copy",
|
|
617
|
+
"-metadata", "creation_time=$formattedDateTime",
|
|
618
|
+
resolvedOutputFile
|
|
619
|
+
)
|
|
620
|
+
} else {
|
|
621
|
+
// Stream copy: no re-encoding, extremely fast but only cuts at keyframes.
|
|
622
|
+
cmds += arrayOf(
|
|
623
|
+
"-i", url,
|
|
624
|
+
"-c", "copy",
|
|
625
|
+
"-metadata", "creation_time=$formattedDateTime",
|
|
626
|
+
resolvedOutputFile
|
|
627
|
+
)
|
|
628
|
+
}
|
|
605
629
|
|
|
606
630
|
Log.d(TAG, "Command: ${cmds.joinToString(",")}")
|
|
607
631
|
|
|
608
632
|
FFmpegKit.executeWithArgumentsAsync(cmds, { session ->
|
|
609
633
|
val state = session.state
|
|
610
634
|
val returnCode = session.returnCode
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
635
|
+
UiThreadUtil.runOnUiThread {
|
|
636
|
+
when {
|
|
637
|
+
ReturnCode.isSuccess(returnCode) -> {
|
|
638
|
+
val duration = endTime - startTime
|
|
639
|
+
val result = Arguments.createMap()
|
|
640
|
+
|
|
641
|
+
result.putString("outputPath", resolvedOutputFile)
|
|
642
|
+
result.putDouble("startTime", startTime)
|
|
643
|
+
result.putDouble("endTime", endTime)
|
|
644
|
+
result.putDouble("duration", duration)
|
|
645
|
+
result.putBoolean("success", true)
|
|
646
|
+
|
|
647
|
+
if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
|
|
648
|
+
Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
|
|
649
|
+
try {
|
|
650
|
+
StorageUtil.saveVideoToGallery(reactApplicationContext, resolvedOutputFile)
|
|
651
|
+
Log.d(TAG, "Edited video saved to Photo Library successfully.")
|
|
652
|
+
if (options.getBoolean("removeAfterSavedToPhoto")) {
|
|
653
|
+
Log.d(TAG, "Removing file after successful save to photo")
|
|
654
|
+
StorageUtil.deleteFile(resolvedOutputFile)
|
|
655
|
+
}
|
|
632
656
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
657
|
+
promise.resolve(result)
|
|
658
|
+
} catch (e: IOException) {
|
|
659
|
+
e.printStackTrace()
|
|
660
|
+
|
|
661
|
+
if (options.getBoolean("removeAfterFailedToSavePhoto")) {
|
|
662
|
+
Log.d(TAG, "Removing file after failed save to photo")
|
|
663
|
+
StorageUtil.deleteFile(resolvedOutputFile)
|
|
664
|
+
}
|
|
636
665
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
666
|
+
promise.reject(
|
|
667
|
+
Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
|
|
668
|
+
)
|
|
640
669
|
}
|
|
670
|
+
} else {
|
|
671
|
+
Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
|
|
641
672
|
|
|
642
|
-
promise.
|
|
643
|
-
Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
|
|
644
|
-
)
|
|
673
|
+
promise.resolve(result)
|
|
645
674
|
}
|
|
646
|
-
} else {
|
|
647
|
-
Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
|
|
648
|
-
|
|
649
|
-
promise.resolve(result)
|
|
650
675
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
Exception(errorMessage)
|
|
665
|
-
)
|
|
676
|
+
ReturnCode.isCancel(returnCode) -> {
|
|
677
|
+
println("FFmpeg command was cancelled")
|
|
678
|
+
promise.reject(
|
|
679
|
+
Exception("FFmpeg command was cancelled with code $returnCode")
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
else -> {
|
|
683
|
+
val errorMessage = String.format("Command failed with state %s and rc %s.%s", state, returnCode, session.getFailStackTrace())
|
|
684
|
+
Log.d(TAG, errorMessage)
|
|
685
|
+
promise.reject(
|
|
686
|
+
Exception(errorMessage)
|
|
687
|
+
)
|
|
688
|
+
}
|
|
666
689
|
}
|
|
667
690
|
}
|
|
668
691
|
}, { log ->
|
|
@@ -701,6 +724,10 @@ open class BaseVideoTrimModule internal constructor(
|
|
|
701
724
|
reactApplicationContext.currentActivity?.startActivity(Intent.createChooser(shareIntent, "Share file"))
|
|
702
725
|
}
|
|
703
726
|
|
|
727
|
+
fun cleanup() {
|
|
728
|
+
reactApplicationContext.removeLifecycleEventListener(this)
|
|
729
|
+
}
|
|
730
|
+
|
|
704
731
|
companion object {
|
|
705
732
|
const val NAME = "VideoTrim"
|
|
706
733
|
const val TAG = "VideoTrimModule"
|
|
@@ -3,6 +3,7 @@ package com.videotrim.utils
|
|
|
3
3
|
import android.media.MediaMetadataRetriever
|
|
4
4
|
import android.net.Uri
|
|
5
5
|
import android.util.Log
|
|
6
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
6
7
|
import java.io.IOException
|
|
7
8
|
|
|
8
9
|
object MediaMetadataUtil {
|
|
@@ -47,7 +48,7 @@ object MediaMetadataUtil {
|
|
|
47
48
|
Thread {
|
|
48
49
|
val retriever = getMediaMetadataRetriever(urlString)
|
|
49
50
|
if (retriever == null) {
|
|
50
|
-
callback.onResult(false, "unknown", -1L)
|
|
51
|
+
UiThreadUtil.runOnUiThread { callback.onResult(false, "unknown", -1L) }
|
|
51
52
|
return@Thread
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -74,7 +75,10 @@ object MediaMetadataUtil {
|
|
|
74
75
|
} catch (e: IOException) {
|
|
75
76
|
Log.e(TAG, "Error releasing retriever", e)
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
val resultIsValid = isValid
|
|
79
|
+
val resultFileType = fileType
|
|
80
|
+
val resultDuration = if (isValid) duration else -1L
|
|
81
|
+
UiThreadUtil.runOnUiThread { callback.onResult(resultIsValid, resultFileType, resultDuration) }
|
|
78
82
|
}.start()
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -9,6 +9,7 @@ import android.os.Environment
|
|
|
9
9
|
import android.provider.MediaStore
|
|
10
10
|
import android.text.TextUtils
|
|
11
11
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
12
|
+
import iknow.android.utils.BaseUtils
|
|
12
13
|
import java.io.File
|
|
13
14
|
import java.io.FileInputStream
|
|
14
15
|
import java.io.FileOutputStream
|
|
@@ -38,7 +39,10 @@ object StorageUtil {
|
|
|
38
39
|
|
|
39
40
|
fun deleteFile(path: String?): Boolean {
|
|
40
41
|
if (TextUtils.isEmpty(path)) return true
|
|
41
|
-
|
|
42
|
+
val file = File(path!!).canonicalFile
|
|
43
|
+
val allowedDir = BaseUtils.getContext().filesDir.canonicalFile
|
|
44
|
+
if (!file.path.startsWith(allowedDir.path)) return false
|
|
45
|
+
return deleteFile(file)
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
fun deleteFile(file: File?): Boolean {
|