react-native-video-trim 1.0.10 → 1.0.12

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.
@@ -1,13 +1,26 @@
1
1
  import React
2
2
  import Photos
3
+ import ffmpegkit
3
4
 
4
5
  @objc(VideoTrim)
5
- class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationControllerDelegate {
6
- private var isShowing = false
7
- private var mSaveToPhoto = true
8
- private var mMaxDuration: Int?
6
+ class VideoTrim: RCTEventEmitter {
7
+ private let FILE_PREFIX = "trimmedVideo"
9
8
  private var hasListeners = false
10
- private var shouldFireFinishEvent = true
9
+ private var isShowing = false
10
+
11
+ private var saveToPhoto = true
12
+ private var removeAfterSavedToPhoto = false
13
+ private var enableCancelDialog = true
14
+ private var cancelDialogTitle = "Warning!"
15
+ private var cancelDialogMessage = "Are you sure want to cancel?"
16
+ private var cancelDialogCancelText = "Close"
17
+ private var cancelDialogConfirmText = "Proceed"
18
+ private var enableSaveDialog = true
19
+ private var saveDialogTitle = "Confirmation!"
20
+ private var saveDialogMessage = "Are you sure want to save?"
21
+ private var saveDialogCancelText = "Close"
22
+ private var saveDialogConfirmText = "Proceed"
23
+ private var trimmingText = "Trimming video..."
11
24
 
12
25
  @objc
13
26
  static override func requiresMainQueueSetup() -> Bool {
@@ -29,7 +42,8 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
29
42
  @objc(isValidVideo:withResolver:withRejecter:)
30
43
  func isValidVideo(uri: String, resolve: @escaping RCTPromiseResolveBlock,reject: @escaping RCTPromiseRejectBlock) -> Void {
31
44
  if let destPath = copyFileToDocumentDir(uri: uri) {
32
- resolve(UIVideoEditorController.canEditVideo(atPath: destPath))
45
+ resolve(UIVideoEditorController.canEditVideo(atPath: destPath.path))
46
+ let _ = deleteFile(url: destPath) // remove the file we just copied to document directory
33
47
  } else {
34
48
  resolve(false)
35
49
  }
@@ -40,54 +54,119 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
40
54
  if isShowing {
41
55
  return
42
56
  }
57
+
58
+ saveToPhoto = config["saveToPhoto"] as? Bool ?? true
59
+ removeAfterSavedToPhoto = config["removeAfterSavedToPhoto"] as? Bool ?? false
43
60
 
44
- if let saveToPhoto = config["saveToPhoto"] as? Bool {
45
- self.mSaveToPhoto = saveToPhoto
46
- }
61
+ // since RN Module is singleton, so we need to reset values everytime instead of reassign
62
+ // Eg. this will not work if change: cancelDialogTitle = config["cancelDialogTitle"] as? String ?? cancelDialogTitle
63
+ // because if we change cancelDialogTitle, the value is still there, and if from RN side we pass undefined, it'll still have previous value
64
+ enableCancelDialog = config["enableCancelDialog"] as? Bool ?? true
65
+ cancelDialogTitle = config["cancelDialogTitle"] as? String ?? "Warning!"
66
+ cancelDialogMessage = config["cancelDialogMessage"] as? String ?? "Are you sure want to cancel?"
67
+ cancelDialogCancelText = config["cancelDialogCancelText"] as? String ?? "Close"
68
+ cancelDialogConfirmText = config["cancelDialogConfirmText"] as? String ?? "Proceed"
69
+
70
+ enableSaveDialog = config["enableSaveDialog"] as? Bool ?? true
71
+ saveDialogTitle = config["saveDialogTitle"] as? String ?? "Confirmation!"
72
+ saveDialogMessage = config["saveDialogMessage"] as? String ?? "Are you sure want to save?"
73
+ saveDialogCancelText = config["saveDialogCancelText"] as? String ?? "Close"
74
+ saveDialogConfirmText = config["saveDialogConfirmText"] as? String ?? "Proceed"
75
+ trimmingText = config["trimmingText"] as? String ?? "Trimming video..."
47
76
 
48
- if let maxDuration = config["maxDuration"] as? Int {
49
- self.mMaxDuration = maxDuration
50
- }
51
-
52
77
  if let destPath = copyFileToDocumentDir(uri: uri) {
53
- if UIVideoEditorController.canEditVideo(atPath: destPath) {
78
+ if UIVideoEditorController.canEditVideo(atPath: destPath.path) {
54
79
  DispatchQueue.main.async {
55
- let editController = UIVideoEditorController()
56
- editController.videoPath = destPath
57
- editController.videoQuality = .typeHigh
58
-
59
- if (self.mMaxDuration != nil) {
60
- editController.videoMaximumDuration = Double(self.mMaxDuration!)
61
- }
62
-
63
- editController.delegate = self
64
- if let root = RCTPresentedViewController() {
65
- root.present(editController, animated: true, completion: {
66
- self.emitEventToJS("onShow", eventData: nil)
67
- self.isShowing = true
68
- })
80
+ if #available(iOS 13.0, *) {
81
+ let vc = VideoTrimmerViewController()
82
+ vc.asset = AVURLAsset(url: destPath, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true])
69
83
 
70
- // run "during" presenting so that user sees texts update immediately
71
- // putting inside "present" will briefly show old texts then changed to new one, user can clearly see this
72
- // with out DispatchQueue.main.asyncAfter, topItem is still nil
73
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
74
- if let topItem = editController.navigationBar.topItem {
75
- if let title = config["title"] as? String, !title.isEmpty {
76
- topItem.title = title
77
- }
84
+ if let maxDuration = config["maxDuration"] as? Int {
85
+ vc.maximumDuration = maxDuration
86
+ }
87
+
88
+ if let cancelBtnText = config["cancelButtonText"] as? String, !cancelBtnText.isEmpty {
89
+ vc.cancelBtnText = cancelBtnText
90
+ }
91
+
92
+ if let saveButtonText = config["saveButtonText"] as? String, !saveButtonText.isEmpty {
93
+ vc.saveButtonText = saveButtonText
94
+ }
95
+
96
+ vc.cancelBtnClicked = {
97
+ if !self.enableCancelDialog {
98
+ let _ = self.deleteFile(url: destPath) // remove the file we just copied to document directory
99
+ self.emitEventToJS("onCancelTrimming", eventData: nil)
78
100
 
79
- // when it comes to bar button customization
80
- // we can't customize text of original buttons here, we can only set attrs like enabled/hidden
81
- // to customize text we need to create new button
82
- if let cancelBtnText = config["cancelButtonText"] as? String, !cancelBtnText.isEmpty {
83
- topItem.leftBarButtonItem = UIBarButtonItem(title: cancelBtnText, style: topItem.leftBarButtonItem?.style ?? .plain, target: topItem.leftBarButtonItem?.target, action: topItem.leftBarButtonItem?.action)
84
- }
101
+ vc.dismiss(animated: true, completion: {
102
+ self.emitEventToJS("onHide", eventData: nil)
103
+ self.isShowing = false
104
+ })
105
+ return
106
+ }
107
+
108
+ // Create Alert
109
+ let dialogMessage = UIAlertController(title: self.cancelDialogTitle, message: self.cancelDialogMessage, preferredStyle: .alert)
110
+
111
+ // Create OK button with action handler
112
+ let ok = UIAlertAction(title: self.cancelDialogConfirmText, style: .destructive, handler: { (action) -> Void in
113
+ let _ = self.deleteFile(url: destPath) // remove the file we just copied to document directory
114
+ self.emitEventToJS("onCancelTrimming", eventData: nil)
85
115
 
86
- if let saveBtnText = config["saveButtonText"] as? String, !saveBtnText.isEmpty {
87
- topItem.rightBarButtonItem = UIBarButtonItem(title: saveBtnText, style: topItem.rightBarButtonItem?.style ?? .plain, target: topItem.rightBarButtonItem?.target, action: topItem.rightBarButtonItem?.action)
88
- }
116
+ vc.dismiss(animated: true, completion: {
117
+ self.emitEventToJS("onHide", eventData: nil)
118
+ self.isShowing = false
119
+ })
120
+ })
121
+
122
+ // Create Cancel button with action handlder
123
+ let cancel = UIAlertAction(title: self.cancelDialogCancelText, style: .cancel)
124
+
125
+ //Add OK and Cancel button to an Alert object
126
+ dialogMessage.addAction(ok)
127
+ dialogMessage.addAction(cancel)
128
+
129
+ // Present alert message to user
130
+ if let root = RCTPresentedViewController() {
131
+ root.present(dialogMessage, animated: true, completion: nil)
89
132
  }
90
133
  }
134
+
135
+ vc.saveBtnClicked = {(selectedRange: CMTimeRange) in
136
+ if !self.enableSaveDialog {
137
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: vc.asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds)
138
+ return
139
+ }
140
+
141
+ // Create Alert
142
+ let dialogMessage = UIAlertController(title: self.saveDialogTitle, message: self.saveDialogMessage, preferredStyle: .alert)
143
+
144
+ // Create OK button with action handler
145
+ let ok = UIAlertAction(title: self.saveDialogConfirmText, style: .default, handler: { (action) -> Void in
146
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: vc.asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds)
147
+ })
148
+
149
+ // Create Cancel button with action handlder
150
+ let cancel = UIAlertAction(title: self.saveDialogCancelText, style: .cancel)
151
+
152
+ //Add OK and Cancel button to an Alert object
153
+ dialogMessage.addAction(ok)
154
+ dialogMessage.addAction(cancel)
155
+
156
+ // Present alert message to user
157
+ if let root = RCTPresentedViewController() {
158
+ root.present(dialogMessage, animated: true, completion: nil)
159
+ }
160
+ }
161
+
162
+ vc.isModalInPresentation = true // prevent modal closed by swipe down
163
+
164
+ if let root = RCTPresentedViewController() {
165
+ root.present(vc, animated: true, completion: {
166
+ self.emitEventToJS("onShow", eventData: nil)
167
+ self.isShowing = true
168
+ })
169
+ }
91
170
  }
92
171
  }
93
172
  } else {
@@ -100,72 +179,7 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
100
179
  }
101
180
  }
102
181
 
103
- func videoEditorController(_ editor: UIVideoEditorController,
104
- didSaveEditedVideoToPath editedVideoPath: String) {
105
- if (!shouldFireFinishEvent) {
106
- return
107
- }
108
- shouldFireFinishEvent = false
109
-
110
- let eventPayload: [String: Any] = ["outputPath": editedVideoPath]
111
- self.emitEventToJS("onFinishTrimming", eventData: eventPayload)
112
-
113
- if (mSaveToPhoto) {
114
- PHPhotoLibrary.requestAuthorization { status in
115
- guard status == .authorized else {
116
- let eventPayload: [String: Any] = ["message": "Permission to access Photo Library is not granted"]
117
- self.emitEventToJS("onError", eventData: eventPayload)
118
- return
119
- }
120
-
121
- PHPhotoLibrary.shared().performChanges({
122
- let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: editedVideoPath))
123
- request?.creationDate = Date()
124
- }) { success, error in
125
- if success {
126
- print("Edited video saved to Photo Library successfully.")
127
- } else {
128
- let eventPayload: [String: Any] = ["message": "Failed to save edited video to Photo Library: \(error?.localizedDescription ?? "Unknown error")"]
129
- self.emitEventToJS("onError", eventData: eventPayload)
130
- }
131
- }
132
- }
133
- }
134
-
135
- // the edit has a known bug where it fires "didSaveEditedVideoToPath" twice, so we have to set its delete to nil right after first call
136
- // editor.delegate = nil
137
-
138
- // but with the above solution, somehow it'll close React Native Modal when the editor controller dismissed
139
- // so we have to create a flag shouldFireFinishEvent here
140
-
141
-
142
- editor.dismiss(animated: true, completion: {
143
- self.emitEventToJS("onHide", eventData: nil)
144
- self.isShowing = false
145
- self.shouldFireFinishEvent = true // reset this flag to true once dismiss
146
- })
147
- }
148
-
149
- func videoEditorControllerDidCancel(_ editor: UIVideoEditorController) {
150
- self.emitEventToJS("onCancelTrimming", eventData: nil)
151
- editor.dismiss(animated: true, completion: {
152
- self.emitEventToJS("onHide", eventData: nil)
153
- self.isShowing = false
154
- })
155
- }
156
-
157
- func videoEditorController(_ editor: UIVideoEditorController,
158
- didFailWithError error: Error) {
159
- let eventPayload: [String: Any] = ["message": error.localizedDescription]
160
- self.emitEventToJS("onError", eventData: eventPayload)
161
- editor.dismiss(animated: true, completion: {
162
- self.emitEventToJS("onHide", eventData: nil)
163
- self.isShowing = false
164
- })
165
- }
166
-
167
-
168
- private func copyFileToDocumentDir(uri: String) -> String? {
182
+ private func copyFileToDocumentDir(uri: String) -> URL? {
169
183
  if let videoURL = URL(string: uri) {
170
184
  // Save the video to the document directory
171
185
  let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@@ -173,20 +187,17 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
173
187
  let fileExtension = videoURL.pathExtension
174
188
 
175
189
  // Define the filename with the correct file extension
176
- let destinationURL = documentsDirectory.appendingPathComponent("editedVideo.\(fileExtension)")
190
+ let timestamp = Int(Date().timeIntervalSince1970)
191
+ let destinationURL = documentsDirectory.appendingPathComponent("\(FILE_PREFIX)_original_\(timestamp).\(fileExtension)")
177
192
 
178
193
  do {
179
- // Remove the old file if it exists
180
- if FileManager.default.fileExists(atPath: destinationURL.path) {
181
- try FileManager.default.removeItem(at: destinationURL)
182
- }
183
-
184
194
  try FileManager.default.copyItem(at: videoURL, to: destinationURL)
185
195
  } catch {
196
+ print("Error while copying file to document directory \(error)")
186
197
  return nil
187
198
  }
188
199
 
189
- return destinationURL.path
200
+ return destinationURL
190
201
  } else {
191
202
  return nil
192
203
  }
@@ -199,4 +210,157 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
199
210
  sendEvent(withName: "VideoTrim", body: modifiedEventData)
200
211
  }
201
212
  }
213
+
214
+ @objc(listFiles:withRejecter:)
215
+ func listFiles(resolve: @escaping RCTPromiseResolveBlock,reject: @escaping RCTPromiseRejectBlock) -> Void {
216
+ let files = listFiles()
217
+ resolve(files.map{ $0.absoluteString })
218
+ }
219
+
220
+ @objc(cleanFiles:withRejecter:)
221
+ func cleanFiles(resolve: @escaping RCTPromiseResolveBlock,reject: @escaping RCTPromiseRejectBlock) -> Void {
222
+ let files = listFiles()
223
+ var successCount = 0
224
+ for file in files {
225
+ let state = deleteFile(url: file)
226
+
227
+ if state == 0 {
228
+ successCount += 1
229
+ }
230
+ }
231
+
232
+ resolve(successCount)
233
+ }
234
+
235
+ @objc(deleteFile:withResolver:withRejecter:)
236
+ func deleteFile(uri: String, resolve: @escaping RCTPromiseResolveBlock,reject: @escaping RCTPromiseRejectBlock) -> Void {
237
+ let state = deleteFile(url: URL(string: uri)!)
238
+ resolve(state == 0)
239
+ }
240
+
241
+ private func listFiles() -> [URL] {
242
+ var files: [URL] = []
243
+
244
+ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
245
+
246
+ do {
247
+ let directoryContents = try FileManager.default.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil)
248
+
249
+ for fileURL in directoryContents {
250
+ if fileURL.lastPathComponent.starts(with: FILE_PREFIX) {
251
+ files.append(fileURL)
252
+ }
253
+ }
254
+ } catch {
255
+ print("[listFiles] Error when retrieving files: \(error)")
256
+ }
257
+
258
+ return files
259
+ }
260
+
261
+ private func deleteFile(url: URL) -> Int {
262
+ do {
263
+ if FileManager.default.fileExists(atPath: url.path) {
264
+ try FileManager.default.removeItem(at: url)
265
+
266
+ return 0
267
+ }
268
+
269
+ return 1
270
+ } catch {
271
+ print("[deleteFile] Error deleting files: \(error)")
272
+
273
+ return 2
274
+ }
275
+ }
276
+
277
+ @available(iOS 13.0, *)
278
+ private func trim(viewController: VideoTrimmerViewController, inputFile: URL, videoDuration: Double, startTime: Double, endTime: Double) {
279
+ let timestamp = Int(Date().timeIntervalSince1970)
280
+ let outputName = "\(FILE_PREFIX)_\(timestamp).mp4" // use mp4 to prevent any issue with ffmpeg about file extension
281
+ let outputFile = "\(inputFile.deletingLastPathComponent().absoluteURL)\(outputName)"
282
+ let cmd = "-i \(inputFile) -ss \(startTime * 1000)ms -to \(endTime * 1000)ms -c copy \(outputFile)";
283
+
284
+ self.emitEventToJS("onStartTrimming", eventData: nil)
285
+
286
+ // Create Alert
287
+ let dialogMessage = UIAlertController(title: trimmingText, message: nil, preferredStyle: .alert)
288
+
289
+ // Present alert message to user
290
+ let progressView = UIProgressView(frame: .zero)
291
+ progressView.tintColor = .systemBlue
292
+ if let root = RCTPresentedViewController() {
293
+ root.present(dialogMessage, animated: true, completion: {
294
+ dialogMessage.view.addSubview(progressView)
295
+
296
+ progressView.translatesAutoresizingMaskIntoConstraints = false
297
+ NSLayoutConstraint.activate([
298
+ progressView.leadingAnchor.constraint(equalTo: dialogMessage.view.leadingAnchor, constant: 8),
299
+ progressView.trailingAnchor.constraint(equalTo: dialogMessage.view.trailingAnchor, constant: -8),
300
+ progressView.bottomAnchor.constraint(equalTo: dialogMessage.view.bottomAnchor, constant: -8)
301
+ ])
302
+ })
303
+ }
304
+
305
+ FFmpegKit.executeAsync(cmd, withCompleteCallback: { session in
306
+ let _ = self.deleteFile(url: inputFile) // remove the file we just copied to document directory
307
+
308
+ let state = session?.getState()
309
+
310
+ if state == .completed {
311
+ let eventPayload: [String: Any] = ["outputPath": outputFile]
312
+ self.emitEventToJS("onFinishTrimming", eventData: eventPayload)
313
+
314
+ if (self.saveToPhoto) {
315
+ PHPhotoLibrary.requestAuthorization { status in
316
+ guard status == .authorized else {
317
+ let eventPayload: [String: Any] = ["message": "Permission to access Photo Library is not granted"]
318
+ self.emitEventToJS("onError", eventData: eventPayload)
319
+ return
320
+ }
321
+
322
+ PHPhotoLibrary.shared().performChanges({
323
+ let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(string: outputFile)!)
324
+ request?.creationDate = Date()
325
+ }) { success, error in
326
+ if success {
327
+ print("Edited video saved to Photo Library successfully.")
328
+
329
+ if self.removeAfterSavedToPhoto {
330
+ let _ = self.deleteFile(url: URL(string: outputFile)!)
331
+ }
332
+ } else {
333
+ let eventPayload: [String: Any] = ["message": "Failed to save edited video to Photo Library: \(error?.localizedDescription ?? "Unknown error")"]
334
+ self.emitEventToJS("onError", eventData: eventPayload)
335
+ }
336
+ }
337
+ }
338
+ }
339
+ } else {
340
+ let eventPayload: [String: Any] = ["message": "Some error occured"]
341
+ self.emitEventToJS("onError", eventData: eventPayload)
342
+ }
343
+
344
+ // some how in case we trim a very short video the view controller is still visible after first .dismiss call
345
+ // even the file is successfully saved
346
+ // that's why we need a small delay here to ensure vc will be dismissed
347
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
348
+ dialogMessage.dismiss(animated: false)
349
+ viewController.dismiss(animated: true, completion: {
350
+ self.emitEventToJS("onHide", eventData: nil)
351
+ self.isShowing = false
352
+ })
353
+ }
354
+ }, withLogCallback: { log in
355
+
356
+ }, withStatisticsCallback: { statistics in
357
+ let timeInMilliseconds = statistics?.getTime() ?? 0;
358
+ if timeInMilliseconds > 0 {
359
+ let completePercentage = timeInMilliseconds / (videoDuration * 1000); // from 0 -> 1
360
+ DispatchQueue.main.async {
361
+ progressView.setProgress(Float(completePercentage), animated: true)
362
+ }
363
+ }
364
+ })
365
+ }
202
366
  }