react-native-video-trim 6.2.2 → 6.2.3

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.
@@ -75,6 +75,8 @@ open class BaseVideoTrimModule internal constructor(
75
75
  get() = editorConfig?.hasKey("changeStatusBarColorOnOpen") == true && editorConfig?.getBoolean("changeStatusBarColorOnOpen") == true
76
76
 
77
77
  init {
78
+ reactApplicationContext.addLifecycleEventListener(this)
79
+
78
80
  val mActivityEventListener = object : BaseActivityEventListener() {
79
81
  override fun onActivityResult(
80
82
  activity: Activity,
@@ -137,7 +139,10 @@ open class BaseVideoTrimModule internal constructor(
137
139
 
138
140
  this.isVideoType = config.hasKey("type") && config.getString("type") == "video"
139
141
 
140
- val activity = reactApplicationContext.currentActivity
142
+ val activity = reactApplicationContext.currentActivity ?: run {
143
+ onError("Activity is not available", ErrorCode.UNKNOWN)
144
+ return
145
+ }
141
146
  if (!isInit) {
142
147
  init()
143
148
  isInit = true
@@ -150,7 +155,7 @@ open class BaseVideoTrimModule internal constructor(
150
155
  trimmerView?.initByURI(filePath.toUri())
151
156
 
152
157
  val builder = AlertDialog.Builder(
153
- activity!!, Theme_Black_NoTitleBar_Fullscreen
158
+ activity, Theme_Black_NoTitleBar_Fullscreen
154
159
  )
155
160
  builder.setCancelable(false)
156
161
  alertDialog = builder.create()
@@ -158,18 +163,17 @@ open class BaseVideoTrimModule internal constructor(
158
163
 
159
164
  // Apply safe area handling after the dialog is shown
160
165
  alertDialog?.setOnShowListener {
161
- applySafeAreaToDialog(alertDialog!!, trimmerView!!)
166
+ val dialog = alertDialog ?: return@setOnShowListener
167
+ val view = trimmerView ?: return@setOnShowListener
168
+ applySafeAreaToDialog(dialog, view)
162
169
 
163
170
  sendEvent("onShow", null)
164
171
  }
165
172
 
166
173
  // 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!!.setOnDismissListener {
168
- // This is called in same thread as the trimmer view -> UI thread
169
- if (trimmerView != null) {
170
- trimmerView!!.onDestroy()
171
- trimmerView = null
172
- }
174
+ alertDialog?.setOnDismissListener {
175
+ trimmerView?.onDestroy()
176
+ trimmerView = null
173
177
  hideDialog(true)
174
178
  sendEvent("onHide", null)
175
179
  }
@@ -195,7 +199,7 @@ open class BaseVideoTrimModule internal constructor(
195
199
  it.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
196
200
 
197
201
  // finally change the color
198
- it.statusBarColor = ContextCompat.getColor(reactApplicationContext.currentActivity!!,R.color.black)
202
+ it.statusBarColor = ContextCompat.getColor(reactApplicationContext, R.color.black)
199
203
  }
200
204
  }
201
205
 
@@ -251,9 +255,7 @@ open class BaseVideoTrimModule internal constructor(
251
255
 
252
256
  override fun onHostPause() {
253
257
  Log.d(TAG, "onHostPause: ")
254
- if (trimmerView != null) {
255
- trimmerView!!.onMediaPause()
256
- }
258
+ trimmerView?.onMediaPause()
257
259
  }
258
260
 
259
261
  override fun onHostDestroy() {
@@ -277,7 +279,7 @@ open class BaseVideoTrimModule internal constructor(
277
279
  return
278
280
  }
279
281
 
280
- mProgressBar!!.setProgress(percentage, true)
282
+ mProgressBar?.setProgress(percentage, true)
281
283
  }
282
284
 
283
285
 
@@ -312,10 +314,11 @@ open class BaseVideoTrimModule internal constructor(
312
314
  hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
313
315
  }
314
316
  } else if (editorConfig?.getBoolean("openDocumentsOnFinish") == true) {
315
- saveFileToExternalStorage(File(outputFile!!))
317
+ val output = outputFile ?: return
318
+ saveFileToExternalStorage(File(output))
316
319
  } else if (editorConfig?.getBoolean("openShareSheetOnFinish") == true) {
317
320
  hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
318
- shareFile(reactApplicationContext, File(outputFile!!))
321
+ outputFile?.let { shareFile(reactApplicationContext, File(it)) }
319
322
  } else {
320
323
  hideDialog(editorConfig?.getBoolean("closeWhenFinish") ?: true)
321
324
  }
@@ -333,13 +336,14 @@ open class BaseVideoTrimModule internal constructor(
333
336
  }
334
337
 
335
338
  override fun onCancel() {
336
- if (!editorConfig?.getBoolean("enableCancelDialog")!!) {
339
+ if (editorConfig?.getBoolean("enableCancelDialog") != true) {
337
340
  sendEvent("onCancel", null)
338
341
  hideDialog(true)
339
342
  return
340
343
  }
341
344
 
342
- val builder = AlertDialog.Builder(reactApplicationContext.currentActivity!!)
345
+ val activity = reactApplicationContext.currentActivity ?: return
346
+ val builder = AlertDialog.Builder(activity)
343
347
  builder.setMessage(editorConfig?.getString("cancelDialogMessage"))
344
348
  builder.setTitle(editorConfig?.getString("cancelDialogTitle"))
345
349
  builder.setCancelable(false)
@@ -358,12 +362,13 @@ open class BaseVideoTrimModule internal constructor(
358
362
  }
359
363
 
360
364
  override fun onSave() {
361
- if (!editorConfig?.getBoolean("enableSaveDialog")!!) {
365
+ if (editorConfig?.getBoolean("enableSaveDialog") != true) {
362
366
  startTrim()
363
367
  return
364
368
  }
365
369
 
366
- val builder = AlertDialog.Builder(reactApplicationContext.currentActivity!!)
370
+ val activity = reactApplicationContext.currentActivity ?: return
371
+ val builder = AlertDialog.Builder(activity)
367
372
  builder.setMessage(editorConfig?.getString("saveDialogMessage"))
368
373
  builder.setTitle(editorConfig?.getString("saveDialogTitle"))
369
374
  builder.setCancelable(false)
@@ -389,7 +394,7 @@ open class BaseVideoTrimModule internal constructor(
389
394
  }
390
395
 
391
396
  private fun startTrim() {
392
- val activity = reactApplicationContext.currentActivity
397
+ val activity = reactApplicationContext.currentActivity ?: return
393
398
  // Create the parent layout for the dialog
394
399
  val layout = LinearLayout(activity)
395
400
  layout.layoutParams = ViewGroup.LayoutParams(
@@ -413,12 +418,13 @@ open class BaseVideoTrimModule internal constructor(
413
418
  layout.addView(textView)
414
419
 
415
420
  // Create and add the ProgressBar
416
- mProgressBar = ProgressBar(activity, null, progressBarStyleHorizontal)
417
- mProgressBar!!.layoutParams = ViewGroup.LayoutParams(
418
- ViewGroup.LayoutParams.MATCH_PARENT,
419
- ViewGroup.LayoutParams.WRAP_CONTENT
420
- )
421
- mProgressBar!!.progressTintList = ColorStateList.valueOf("#2196F3".toColorInt())
421
+ mProgressBar = ProgressBar(activity, null, progressBarStyleHorizontal).also {
422
+ it.layoutParams = ViewGroup.LayoutParams(
423
+ ViewGroup.LayoutParams.MATCH_PARENT,
424
+ ViewGroup.LayoutParams.WRAP_CONTENT
425
+ )
426
+ it.progressTintList = ColorStateList.valueOf("#2196F3".toColorInt())
427
+ }
422
428
  layout.addView(mProgressBar)
423
429
 
424
430
  // Create button
@@ -433,10 +439,10 @@ open class BaseVideoTrimModule internal constructor(
433
439
  ?: "Cancel Trimming"
434
440
  button.setTextColor(
435
441
  ContextCompat.getColor(
436
- activity!!,
442
+ activity,
437
443
  holo_red_light
438
444
  )
439
- ) // or use your custom color
445
+ )
440
446
 
441
447
  // Apply ripple effect while keeping the button background transparent
442
448
  val outValue = TypedValue()
@@ -451,11 +457,9 @@ open class BaseVideoTrimModule internal constructor(
451
457
  builder.setTitle(editorConfig?.getString("cancelTrimmingDialogTitle"))
452
458
  builder.setCancelable(false)
453
459
  builder.setPositiveButton(editorConfig?.getString("cancelTrimmingDialogConfirmText")) { _: DialogInterface?, _: Int ->
454
- if (trimmerView != null) {
455
- trimmerView!!.onCancelTrimClicked()
456
- }
457
- if (mProgressDialog != null && mProgressDialog!!.isShowing) {
458
- mProgressDialog!!.dismiss()
460
+ trimmerView?.onCancelTrimClicked()
461
+ if (mProgressDialog?.isShowing == true) {
462
+ mProgressDialog?.dismiss()
459
463
  }
460
464
  }
461
465
  builder.setNegativeButton(
@@ -464,14 +468,12 @@ open class BaseVideoTrimModule internal constructor(
464
468
  dialog.cancel()
465
469
  }
466
470
  cancelTrimmingConfirmDialog = builder.create()
467
- cancelTrimmingConfirmDialog!!.show()
471
+ cancelTrimmingConfirmDialog?.show()
468
472
  } else {
469
- if (trimmerView != null) {
470
- trimmerView!!.onCancelTrimClicked()
471
- }
473
+ trimmerView?.onCancelTrimClicked()
472
474
 
473
- if (mProgressDialog != null && mProgressDialog!!.isShowing) {
474
- mProgressDialog!!.dismiss()
475
+ if (mProgressDialog?.isShowing == true) {
476
+ mProgressDialog?.dismiss()
475
477
  }
476
478
  }
477
479
  }
@@ -480,7 +482,7 @@ open class BaseVideoTrimModule internal constructor(
480
482
 
481
483
  // Create the AlertDialog
482
484
  val builder = AlertDialog.Builder(
483
- activity!!
485
+ activity
484
486
  )
485
487
  builder.setCancelable(false)
486
488
  builder.setView(layout)
@@ -488,36 +490,29 @@ open class BaseVideoTrimModule internal constructor(
488
490
  // Show the dialog
489
491
  mProgressDialog = builder.create()
490
492
 
491
- mProgressDialog!!.setOnShowListener {
493
+ mProgressDialog?.setOnShowListener {
492
494
  sendEvent("onStartTrimming", null)
493
- if (trimmerView != null) {
494
- trimmerView!!.onSaveClicked()
495
- }
495
+ trimmerView?.onSaveClicked()
496
496
  }
497
497
 
498
- mProgressDialog!!.show()
498
+ mProgressDialog?.show()
499
499
  }
500
500
 
501
501
  private fun hideDialog(shouldCloseEditor: Boolean) {
502
- // handle the case when the cancel dialog is still showing but the trimming is finished
503
- if (cancelTrimmingConfirmDialog != null) {
504
- if (cancelTrimmingConfirmDialog!!.isShowing) {
505
- cancelTrimmingConfirmDialog!!.dismiss()
506
- }
502
+ cancelTrimmingConfirmDialog?.let {
503
+ if (it.isShowing) it.dismiss()
507
504
  cancelTrimmingConfirmDialog = null
508
505
  }
509
506
 
510
- if (mProgressDialog != null) {
511
- if (mProgressDialog!!.isShowing) mProgressDialog!!.dismiss()
507
+ mProgressDialog?.let {
508
+ if (it.isShowing) it.dismiss()
512
509
  mProgressBar = null
513
510
  mProgressDialog = null
514
511
  }
515
512
 
516
513
  if (shouldCloseEditor) {
517
- if (alertDialog != null) {
518
- if (alertDialog!!.isShowing) {
519
- alertDialog!!.dismiss()
520
- }
514
+ alertDialog?.let {
515
+ if (it.isShowing) it.dismiss()
521
516
  alertDialog = null
522
517
  }
523
518
  }
@@ -550,7 +545,6 @@ open class BaseVideoTrimModule internal constructor(
550
545
 
551
546
  fun closeEditor() {
552
547
  hideDialog(true)
553
- sendEvent("onHide", null)
554
548
  }
555
549
 
556
550
  fun isValidFile(url: String, promise: Promise) {
@@ -593,6 +587,11 @@ open class BaseVideoTrimModule internal constructor(
593
587
 
594
588
  val outputFile = StorageUtil.getOutputPath(reactApplicationContext, options?.getString("outputExt") ?: "mp4")
595
589
 
590
+ val resolvedOutputFile = outputFile ?: run {
591
+ promise.reject(Exception("Failed to create output file path"))
592
+ return
593
+ }
594
+
596
595
  cmds += arrayOf(
597
596
  "-i",
598
597
  url,
@@ -600,7 +599,7 @@ open class BaseVideoTrimModule internal constructor(
600
599
  "copy",
601
600
  "-metadata",
602
601
  "creation_time=$formattedDateTime",
603
- outputFile!!
602
+ resolvedOutputFile
604
603
  )
605
604
 
606
605
  Log.d(TAG, "Command: ${cmds.joinToString(",")}")
@@ -608,61 +607,60 @@ open class BaseVideoTrimModule internal constructor(
608
607
  FFmpegKit.executeWithArgumentsAsync(cmds, { session ->
609
608
  val state = session.state
610
609
  val returnCode = session.returnCode
611
- when {
612
- ReturnCode.isSuccess(returnCode) -> {
613
- // SUCCESS
614
- val duration = endTime - startTime
615
- val result = Arguments.createMap()
616
-
617
- result.putString("outputPath", outputFile)
618
- result.putDouble("startTime", startTime)
619
- result.putDouble("endTime", endTime)
620
- result.putDouble("duration", duration)
621
- result.putBoolean("success", true)
622
-
623
- if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
624
- Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
625
- try {
626
- StorageUtil.saveVideoToGallery(reactApplicationContext, outputFile)
627
- Log.d(TAG, "Edited video saved to Photo Library successfully.")
628
- if (options.getBoolean("removeAfterSavedToPhoto")) {
629
- Log.d(TAG, "Removing file after successful save to photo")
630
- StorageUtil.deleteFile(outputFile)
631
- }
610
+ UiThreadUtil.runOnUiThread {
611
+ when {
612
+ ReturnCode.isSuccess(returnCode) -> {
613
+ val duration = endTime - startTime
614
+ val result = Arguments.createMap()
615
+
616
+ result.putString("outputPath", resolvedOutputFile)
617
+ result.putDouble("startTime", startTime)
618
+ result.putDouble("endTime", endTime)
619
+ result.putDouble("duration", duration)
620
+ result.putBoolean("success", true)
621
+
622
+ if (options?.getBoolean("saveToPhoto") == true && options.getString("type") == "video") {
623
+ Log.d(TAG, "Android trim: saveToPhoto is true, attempting to save to gallery")
624
+ try {
625
+ StorageUtil.saveVideoToGallery(reactApplicationContext, resolvedOutputFile)
626
+ Log.d(TAG, "Edited video saved to Photo Library successfully.")
627
+ if (options.getBoolean("removeAfterSavedToPhoto")) {
628
+ Log.d(TAG, "Removing file after successful save to photo")
629
+ StorageUtil.deleteFile(resolvedOutputFile)
630
+ }
632
631
 
633
- promise.resolve(result)
634
- } catch (e: IOException) {
635
- e.printStackTrace()
632
+ promise.resolve(result)
633
+ } catch (e: IOException) {
634
+ e.printStackTrace()
636
635
 
637
- if (options.getBoolean("removeAfterFailedToSavePhoto")) {
638
- Log.d(TAG, "Removing file after failed save to photo")
639
- StorageUtil.deleteFile(outputFile)
636
+ if (options.getBoolean("removeAfterFailedToSavePhoto")) {
637
+ Log.d(TAG, "Removing file after failed save to photo")
638
+ StorageUtil.deleteFile(resolvedOutputFile)
639
+ }
640
+
641
+ promise.reject(
642
+ Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
643
+ )
640
644
  }
645
+ } else {
646
+ Log.d(TAG, "Android trim: saveToPhoto is false or not video type, resolving with structured result")
641
647
 
642
- promise.reject(
643
- Exception("Failed to save edited video to Photo Library: " + e.localizedMessage)
644
- )
648
+ promise.resolve(result)
645
649
  }
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
650
  }
651
- }
652
- ReturnCode.isCancel(returnCode) -> {
653
- // CANCEL
654
- println("FFmpeg command was cancelled")
655
- promise.reject(
656
- Exception("FFmpeg command was cancelled with code $returnCode")
657
- )
658
- }
659
- else -> {
660
- // FAILURE
661
- val errorMessage = String.format("Command failed with state %s and rc %s.%s", state, returnCode, session.getFailStackTrace());
662
- Log.d(TAG, errorMessage)
663
- promise.reject(
664
- Exception(errorMessage)
665
- )
651
+ ReturnCode.isCancel(returnCode) -> {
652
+ println("FFmpeg command was cancelled")
653
+ promise.reject(
654
+ Exception("FFmpeg command was cancelled with code $returnCode")
655
+ )
656
+ }
657
+ else -> {
658
+ val errorMessage = String.format("Command failed with state %s and rc %s.%s", state, returnCode, session.getFailStackTrace())
659
+ Log.d(TAG, errorMessage)
660
+ promise.reject(
661
+ Exception(errorMessage)
662
+ )
663
+ }
666
664
  }
667
665
  }
668
666
  }, { log ->
@@ -701,6 +699,10 @@ open class BaseVideoTrimModule internal constructor(
701
699
  reactApplicationContext.currentActivity?.startActivity(Intent.createChooser(shareIntent, "Share file"))
702
700
  }
703
701
 
702
+ fun cleanup() {
703
+ reactApplicationContext.removeLifecycleEventListener(this)
704
+ }
705
+
704
706
  companion object {
705
707
  const val NAME = "VideoTrim"
706
708
  const val TAG = "VideoTrimModule"
@@ -6,5 +6,6 @@ enum class ErrorCode {
6
6
  FAIL_TO_INITIALIZE_AUDIO_PLAYER,
7
7
  FAIL_TO_LOAD_MEDIA,
8
8
  FAIL_TO_SAVE_TO_PHOTO,
9
- FAIL_TO_SAVE_TO_DOCUMENTS
9
+ FAIL_TO_SAVE_TO_DOCUMENTS,
10
+ UNKNOWN
10
11
  }
@@ -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
- callback.onResult(isValid, fileType, if (isValid) duration else -1L)
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
- return deleteFile(File(path!!))
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 {
@@ -8,6 +8,7 @@ import com.arthenica.ffmpegkit.FFmpegKit
8
8
  import com.arthenica.ffmpegkit.FFmpegSession
9
9
  import com.arthenica.ffmpegkit.ReturnCode
10
10
  import com.facebook.react.bridge.Arguments
11
+ import com.facebook.react.bridge.UiThreadUtil
11
12
  import com.videotrim.enums.ErrorCode
12
13
  import com.videotrim.interfaces.VideoTrimListener
13
14
  import iknow.android.utils.DeviceUtil
@@ -82,16 +83,18 @@ object VideoTrimmerUtil {
82
83
  return FFmpegKit.executeWithArgumentsAsync(command, { session ->
83
84
  val state = session.state
84
85
  val returnCode = session.returnCode
85
- when {
86
- ReturnCode.isSuccess(session.returnCode) -> {
87
- callback.onFinishTrim(outputFile, startMs, endMs, videoDuration)
88
- }
89
- ReturnCode.isCancel(session.returnCode) -> {
90
- callback.onCancelTrim()
91
- }
92
- else -> {
93
- val errorMessage = "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
94
- callback.onError(errorMessage, ErrorCode.TRIMMING_FAILED)
86
+ UiThreadUtil.runOnUiThread {
87
+ when {
88
+ ReturnCode.isSuccess(returnCode) -> {
89
+ callback.onFinishTrim(outputFile, startMs, endMs, videoDuration)
90
+ }
91
+ ReturnCode.isCancel(returnCode) -> {
92
+ callback.onCancelTrim()
93
+ }
94
+ else -> {
95
+ val errorMessage = "Command failed with state $state and rc $returnCode.${session.failStackTrace}"
96
+ callback.onError(errorMessage, ErrorCode.TRIMMING_FAILED)
97
+ }
95
98
  }
96
99
  }
97
100
  }, { log ->
@@ -102,12 +105,16 @@ object VideoTrimmerUtil {
102
105
  map.putString("message", log.message)
103
106
  map.putDouble("sessionId", log.sessionId.toDouble())
104
107
  map.putString("logStr", log.toString())
105
- callback.onLog(map)
108
+ UiThreadUtil.runOnUiThread {
109
+ callback.onLog(map)
110
+ }
106
111
  }, { statistics ->
107
112
  val timeInMilliseconds = statistics.time.toInt()
108
113
  if (timeInMilliseconds > 0) {
109
114
  val completePercentage = (timeInMilliseconds * 100) / videoDuration
110
- callback.onTrimmingProgress(completePercentage.coerceIn(0, 100))
115
+ UiThreadUtil.runOnUiThread {
116
+ callback.onTrimmingProgress(completePercentage.coerceIn(0, 100))
117
+ }
111
118
  }
112
119
 
113
120
  val map = Arguments.createMap()
@@ -120,7 +127,9 @@ object VideoTrimmerUtil {
120
127
  map.putDouble("bitrate", statistics.bitrate.toDouble())
121
128
  map.putDouble("speed", statistics.speed.toDouble())
122
129
  map.putString("statisticsStr", statistics.toString())
123
- callback.onStatistics(map)
130
+ UiThreadUtil.runOnUiThread {
131
+ callback.onStatistics(map)
132
+ }
124
133
  })
125
134
  }
126
135
 
@@ -124,6 +124,8 @@ class VideoTrimmerView(
124
124
  private var isGeneratingThumbnails = false
125
125
 
126
126
  private var mediaMetadataRetriever: MediaMetadataRetriever? = null
127
+ private val retrieverLock = Object()
128
+ @Volatile private var retrieverReleased = false
127
129
  private lateinit var loadingIndicator: ProgressBar
128
130
  private lateinit var saveBtn: TextView
129
131
  private lateinit var cancelBtn: TextView
@@ -158,7 +160,7 @@ class VideoTrimmerView(
158
160
  private fun init(context: ReactApplicationContext, config: ReadableMap?) {
159
161
  mContext = context
160
162
 
161
- context.currentActivity!!.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
163
+ context.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
162
164
  LayoutInflater.from(context).inflate(R.layout.video_trimmer_view, this, true)
163
165
  vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
164
166
 
@@ -256,7 +258,8 @@ class VideoTrimmerView(
256
258
  mediaFailed()
257
259
  mOnTrimVideoListener.onError("Error loading media file. Please try again.", ErrorCode.FAIL_TO_LOAD_MEDIA)
258
260
  if (alertOnFailToLoad) {
259
- val builder = AlertDialog.Builder(mContext.currentActivity!!)
261
+ val activity = mContext.currentActivity ?: return true
262
+ val builder = AlertDialog.Builder(activity)
260
263
  builder.setMessage(alertOnFailMessage)
261
264
  builder.setTitle(alertOnFailTitle)
262
265
  builder.setCancelable(false)
@@ -462,6 +465,7 @@ class VideoTrimmerView(
462
465
  override fun onDestroy() {
463
466
  isGeneratingThumbnails = false
464
467
  BackgroundExecutor.cancelAll("", true)
468
+ BackgroundExecutor.cancelAll("progressive_thumbs", true)
465
469
  UiThreadExecutor.cancelAll("")
466
470
  mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
467
471
  mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
@@ -469,10 +473,13 @@ class VideoTrimmerView(
469
473
 
470
474
  cachedFullViewThumbnails.clear()
471
475
 
472
- try {
473
- mediaMetadataRetriever?.release()
474
- } catch (e: Exception) {
475
- e.printStackTrace()
476
+ synchronized(retrieverLock) {
477
+ retrieverReleased = true
478
+ try {
479
+ mediaMetadataRetriever?.release()
480
+ } catch (e: Exception) {
481
+ e.printStackTrace()
482
+ }
476
483
  }
477
484
 
478
485
  try {
@@ -1069,7 +1076,9 @@ class VideoTrimmerView(
1069
1076
  val clampedTimeUs = maxOf(0L, minOf(timeUs, mDuration * 1000L))
1070
1077
 
1071
1078
  try {
1072
- val bitmap = mediaMetadataRetriever?.getFrameAtTime(clampedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
1079
+ val bitmap = synchronized(retrieverLock) {
1080
+ if (retrieverReleased) null else mediaMetadataRetriever?.getFrameAtTime(clampedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
1081
+ }
1073
1082
  if (bitmap != null && isGeneratingThumbnails && isZoomedIn) {
1074
1083
  UiThreadExecutor.runTask("", {
1075
1084
  if (isZoomedIn && index < mThumbnailContainer.childCount) {
@@ -1,5 +1,5 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <paths xmlns:android="http://schemas.android.com/apk/res/android">
3
3
  <files-path name="internal_files" path="." />
4
- <external-path name="external_files" path="." />
4
+ <cache-path name="cache_files" path="." />
5
5
  </paths>
@@ -293,7 +293,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
293
293
  dialogMessage.overrideUserInterfaceStyle = .dark
294
294
 
295
295
  // Create OK button with action handler
296
- let ok = UIAlertAction(title: self.cancelDialogConfirmText, style: .destructive, handler: { (action) -> Void in
296
+ let ok = UIAlertAction(title: self.cancelTrimmingDialogConfirmText, style: .destructive, handler: { (action) -> Void in
297
297
 
298
298
  if let ffmpegSession = ffmpegSession {
299
299
  ffmpegSession.cancel()
@@ -307,7 +307,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
307
307
  })
308
308
 
309
309
  // Create Cancel button with action handlder
310
- let cancel = UIAlertAction(title: self.cancelDialogCancelText, style: .cancel)
310
+ let cancel = UIAlertAction(title: self.cancelTrimmingDialogCancelText, style: .cancel)
311
311
 
312
312
  //Add OK and Cancel button to an Alert object
313
313
  dialogMessage.addAction(ok)
@@ -347,6 +347,11 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
347
347
  cmds.append(contentsOf: ["-display_rotation", "\(rotationAngle)"])
348
348
  }
349
349
 
350
+ guard let outputFile = outputFile else {
351
+ self.onError(message: "Output file path is nil", code: .trimmingFailed)
352
+ return
353
+ }
354
+
350
355
  cmds.append(contentsOf: [
351
356
  "-i",
352
357
  inputFile.path,
@@ -354,7 +359,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
354
359
  "copy",
355
360
  "-metadata",
356
361
  "creation_time=\(dateTime)",
357
- outputFile!.path
362
+ outputFile.path
358
363
  ])
359
364
 
360
365
  print("Command: ", cmds.joined(separator: " "))
@@ -372,7 +377,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
372
377
  var shouldCloseEditor = false
373
378
 
374
379
  if ReturnCode.isSuccess(returnCode) {
375
- let eventPayload: [String: Any] = ["outputPath": self.outputFile!.absoluteString, "startTime": (startTime * 1000).rounded(), "endTime": (endTime * 1000).rounded(), "duration": (videoDuration * 1000).rounded()]
380
+ let eventPayload: [String: Any] = ["outputPath": outputFile.absoluteString, "startTime": (startTime * 1000).rounded(), "endTime": (endTime * 1000).rounded(), "duration": (videoDuration * 1000).rounded()]
376
381
  self.emitEventToJS("onFinishTrimming", eventData: eventPayload)
377
382
 
378
383
  if (self.saveToPhoto && isVideoType) {
@@ -383,19 +388,19 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
383
388
  }
384
389
 
385
390
  PHPhotoLibrary.shared().performChanges({
386
- let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: self.outputFile!)
391
+ let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: outputFile)
387
392
  request?.creationDate = Date()
388
393
  }) { success, error in
389
394
  if success {
390
395
  print("Edited video saved to Photo Library successfully.")
391
396
 
392
397
  if self.removeAfterSavedToPhoto {
393
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
398
+ let _ = VideoTrim.deleteFile(url: outputFile)
394
399
  }
395
400
  } else {
396
401
  self.onError(message: "Failed to save edited video to Photo Library: \(error?.localizedDescription ?? "Unknown error")", code: .failToSaveToPhoto)
397
402
  if self.removeAfterFailedToSavePhoto {
398
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
403
+ let _ = VideoTrim.deleteFile(url: outputFile)
399
404
  }
400
405
  }
401
406
  }
@@ -404,7 +409,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
404
409
  DispatchQueue.main.async {
405
410
  progressAlert.dismiss(animated: true) {
406
411
  self.isTrimming = false
407
- self.saveFileToFilesApp(fileURL: self.outputFile!)
412
+ self.saveFileToFilesApp(fileURL: outputFile)
408
413
  }
409
414
  }
410
415
  return
@@ -412,7 +417,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
412
417
  DispatchQueue.main.async {
413
418
  progressAlert.dismiss(animated: true) {
414
419
  self.isTrimming = false
415
- self.shareFile(fileURL: self.outputFile!)
420
+ self.shareFile(fileURL: outputFile)
416
421
  }
417
422
  }
418
423
  return
@@ -479,9 +484,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
479
484
  // New Arch
480
485
  @objc(trim:url:config:)
481
486
  public func _trim(inputFile: String, config: NSDictionary, completion: @escaping ([String: Any]) -> Void) {
482
- let destPath = URL(string: inputFile)
483
-
484
- guard let destPath = destPath else {
487
+ guard let destPath = URL(string: inputFile) ?? URL(fileURLWithPath: inputFile) as URL? else {
485
488
  let result = [
486
489
  "success": false,
487
490
  "message": "Invalid input file path",
@@ -677,21 +680,21 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
677
680
  print(message)
678
681
  self.onError(message: message, code: .failToShare)
679
682
 
680
- if self.removeAfterFailedToShare {
681
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
683
+ if self.removeAfterFailedToShare, let outputFile = self.outputFile {
684
+ let _ = VideoTrim.deleteFile(url: outputFile)
682
685
  }
683
686
  return
684
687
  }
685
688
 
686
689
  if completed {
687
690
  print("User completed the sharing activity")
688
- if self.removeAfterShared {
689
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
691
+ if self.removeAfterShared, let outputFile = self.outputFile {
692
+ let _ = VideoTrim.deleteFile(url: outputFile)
690
693
  }
691
694
  } else {
692
695
  print("User cancelled or failed to complete the sharing activity")
693
- if self.removeAfterFailedToShare {
694
- let _ = VideoTrim.deleteFile(url: self.outputFile!)
696
+ if self.removeAfterFailedToShare, let outputFile = self.outputFile {
697
+ let _ = VideoTrim.deleteFile(url: outputFile)
695
698
  }
696
699
  }
697
700
 
@@ -727,10 +730,8 @@ extension VideoTrim {
727
730
  editorConfig = config
728
731
  print("Show editor called with URI: \(uri)")
729
732
 
730
- let destPath = URL(string: uri)
731
- print("Destination Path: \(destPath!.absoluteString), path: \(destPath!.path)")
732
-
733
- guard let destPath = destPath else { return }
733
+ guard let destPath = URL(string: uri) ?? URL(fileURLWithPath: uri) as URL? else { return }
734
+ print("Destination Path: \(destPath.absoluteString), path: \(destPath.path)")
734
735
 
735
736
  DispatchQueue.main.async {
736
737
  self.vc = VideoTrimmerViewController()
@@ -773,8 +774,9 @@ extension VideoTrim {
773
774
  let isVideoType = (config["type"] as? String ?? "video") == "video"
774
775
 
775
776
  vc.saveBtnClicked = {(selectedRange: CMTimeRange) in
777
+ guard let asset = vc.asset else { return }
776
778
  if !self.enableSaveDialog {
777
- self.trim(viewController: vc,inputFile: destPath, videoDuration: self.vc!.asset!.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
779
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
778
780
  return
779
781
  }
780
782
 
@@ -784,7 +786,7 @@ extension VideoTrim {
784
786
 
785
787
  // Create OK button with action handler
786
788
  let ok = UIAlertAction(title: self.saveDialogConfirmText, style: .default, handler: { (action) -> Void in
787
- self.trim(viewController: vc,inputFile: destPath, videoDuration: vc.asset!.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
789
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds, isVideoType: isVideoType)
788
790
  })
789
791
 
790
792
  // Create Cancel button with action handlder
@@ -829,6 +831,7 @@ extension VideoTrim {
829
831
  vc.dismiss(animated: true, completion: {
830
832
  self.emitEventToJS("onHide", eventData: nil)
831
833
  self.isShowing = false
834
+ self.vc = nil
832
835
  })
833
836
  }
834
837
 
@@ -885,7 +888,8 @@ extension VideoTrim {
885
888
  // New Arch
886
889
  @objc(deleteFile:)
887
890
  public static func deleteFile(uri: String) -> Bool {
888
- let state = deleteFile(url: URL(string: uri)!)
891
+ guard let url = URL(string: uri) else { return false }
892
+ let state = deleteFile(url: url)
889
893
  return state == 0
890
894
  }
891
895
 
@@ -918,7 +922,10 @@ extension VideoTrim {
918
922
  // New Arch
919
923
  @objc(isValidFile:url:)
920
924
  public static func isValidFile(url: String, completion: @escaping ([String: Any]) -> Void) -> Void {
921
- let fileURL = URL(string: url)!
925
+ guard let fileURL = URL(string: url) ?? URL(fileURLWithPath: url) as URL? else {
926
+ completion(["isValid": false, "fileType": "unknown", "duration": -1])
927
+ return
928
+ }
922
929
  checkFileValidity(url: fileURL) { isValid, fileType, duration in
923
930
  if isValid {
924
931
  print("Valid \(fileType) file with duration: \(duration) milliseconds")
@@ -1018,8 +1025,9 @@ extension VideoTrim {
1018
1025
 
1019
1026
  vc?.asset = loader.asset
1020
1027
 
1028
+ let duration = loader.asset?.duration.seconds ?? 0
1021
1029
  let eventPayload: [String: Any] = [
1022
- "duration": loader.asset!.duration.seconds * 1000,
1030
+ "duration": duration * 1000,
1023
1031
  ]
1024
1032
  self.emitEventToJS("onLoad", eventData: eventPayload)
1025
1033
  }
@@ -1029,15 +1037,15 @@ extension VideoTrim {
1029
1037
  // MARK: DocumentPicker delegate
1030
1038
  extension VideoTrim {
1031
1039
  public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
1032
- if removeAfterSavedToDocuments {
1033
- let _ = VideoTrim.deleteFile(url: outputFile!)
1040
+ if removeAfterSavedToDocuments, let outputFile = self.outputFile {
1041
+ let _ = VideoTrim.deleteFile(url: outputFile)
1034
1042
  }
1035
1043
  closeEditor()
1036
1044
  }
1037
1045
 
1038
1046
  public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
1039
- if removeAfterFailedToSaveDocuments {
1040
- let _ = VideoTrim.deleteFile(url: outputFile!)
1047
+ if removeAfterFailedToSaveDocuments, let outputFile = self.outputFile {
1048
+ let _ = VideoTrim.deleteFile(url: outputFile)
1041
1049
  }
1042
1050
  closeEditor()
1043
1051
  }
@@ -446,17 +446,15 @@ import AVFoundation
446
446
  self.thumbnails.removeAll(where: {uuidsToRemove.contains($0.uuid)})
447
447
  })
448
448
 
449
- var seenIndex = 0
450
449
  generator.requestedTimeToleranceBefore = .zero
451
450
  generator.requestedTimeToleranceAfter = .zero
452
451
  generator.generateCGImagesAsynchronously(forTimes: times) { requestedTime, cgImage, actualTime, result, error in
453
452
  DispatchQueue.main.async {
454
- seenIndex += 1
455
-
456
453
  guard let cgImage = cgImage else {return}
454
+ guard let index = newThumbnails.firstIndex(where: { CMTimeCompare($0.time, requestedTime) == 0 }) else {return}
457
455
  let image = UIImage(cgImage: cgImage)
458
456
 
459
- let imageView = newThumbnails[seenIndex - 1].imageView
457
+ let imageView = newThumbnails[index].imageView
460
458
  UIView.transition(with: imageView, duration: 0.25, options: [.transitionCrossDissolve], animations: {
461
459
  imageView.image = image
462
460
  })
@@ -69,6 +69,7 @@ class VideoTrimmerViewController: UIViewController {
69
69
  private let audioBannerView = UIImage(systemName: "airpodsmax")
70
70
  private var player: AVPlayer! { playerController.player }
71
71
  private var timeObserverToken: Any?
72
+ private var statusObservation: NSKeyValueObservation?
72
73
  private var autoplay = false
73
74
  private var jumpToPositionOnLoad: Double = 0;
74
75
  private var headerText: String?
@@ -197,8 +198,8 @@ class VideoTrimmerViewController: UIViewController {
197
198
  guard let _ = asset else { return }
198
199
  player.pause()
199
200
 
200
- // Clean up the observer
201
- player.removeObserver(self, forKeyPath: "status")
201
+ statusObservation?.invalidate()
202
+ statusObservation = nil
202
203
 
203
204
  if let token = timeObserverToken {
204
205
  player.removeTimeObserver(token)
@@ -391,15 +392,19 @@ class VideoTrimmerViewController: UIViewController {
391
392
  }
392
393
 
393
394
  private func setupPlayerController() {
395
+ guard let asset = asset else { return }
394
396
  playerController.showsPlaybackControls = false
395
397
  if #available(iOS 16.0, *) {
396
398
  playerController.allowsVideoFrameAnalysis = false
397
399
  }
398
400
  playerController.player = AVPlayer()
399
- player.replaceCurrentItem(with: AVPlayerItem(asset: asset!))
401
+ player.replaceCurrentItem(with: AVPlayerItem(asset: asset))
400
402
 
401
- // Add observer for player status
402
- player.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)
403
+ statusObservation = player.observe(\.status, options: [.new, .initial]) { [weak self] player, _ in
404
+ DispatchQueue.main.async {
405
+ self?.onPlayerReady()
406
+ }
407
+ }
403
408
 
404
409
  try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
405
410
  addChild(playerController)
@@ -512,35 +517,33 @@ class VideoTrimmerViewController: UIViewController {
512
517
  }
513
518
  }
514
519
 
515
- override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
516
- if keyPath == "status" {
517
- if player.status == .readyToPlay {
518
- loadingIndicator.stopAnimating()
519
- btnStackView.removeArrangedSubview(loadingIndicator)
520
- loadingIndicator.removeFromSuperview()
521
- btnStackView.insertArrangedSubview(playBtn, at: 1)
522
-
523
- UIView.animate(withDuration: 0.25, animations: {
524
- self.playBtn.alpha = 1
525
- self.playBtn.isEnabled = true
526
- self.saveBtn.alpha = 1
527
- self.saveBtn.isEnabled = true
528
- })
529
-
530
- if jumpToPositionOnLoad > 0 {
531
- let duration = (asset?.duration.seconds ?? 0) * 1000
532
- let time = jumpToPositionOnLoad > duration ? duration : jumpToPositionOnLoad
533
- let cmtime = CMTime(value: CMTimeValue(time), timescale: 1000)
534
-
535
- self.seek(to: cmtime)
536
- self.trimmer.progress = cmtime
537
- self.currentTimeLabel.text = self.trimmer.progress.displayString
538
- }
539
-
540
- if autoplay {
541
- togglePlay(sender: playBtn)
542
- }
543
- }
520
+ private func onPlayerReady() {
521
+ guard player.status == .readyToPlay else { return }
522
+
523
+ loadingIndicator.stopAnimating()
524
+ btnStackView.removeArrangedSubview(loadingIndicator)
525
+ loadingIndicator.removeFromSuperview()
526
+ btnStackView.insertArrangedSubview(playBtn, at: 1)
527
+
528
+ UIView.animate(withDuration: 0.25, animations: {
529
+ self.playBtn.alpha = 1
530
+ self.playBtn.isEnabled = true
531
+ self.saveBtn.alpha = 1
532
+ self.saveBtn.isEnabled = true
533
+ })
534
+
535
+ if jumpToPositionOnLoad > 0 {
536
+ let duration = (asset?.duration.seconds ?? 0) * 1000
537
+ let time = jumpToPositionOnLoad > duration ? duration : jumpToPositionOnLoad
538
+ let cmtime = CMTime(value: CMTimeValue(time), timescale: 1000)
539
+
540
+ self.seek(to: cmtime)
541
+ self.trimmer.progress = cmtime
542
+ self.currentTimeLabel.text = self.trimmer.progress.displayString
543
+ }
544
+
545
+ if autoplay {
546
+ togglePlay(sender: playBtn)
544
547
  }
545
548
  }
546
549
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-video-trim",
3
- "version": "6.2.2",
3
+ "version": "6.2.3",
4
4
  "description": "Video trimmer for your React Native app",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",