react-native-video-trim 1.0.10 → 1.0.11

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,117 @@ 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
+ self.emitEventToJS("onCancelTrimming", eventData: nil)
78
99
 
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
- }
100
+ vc.dismiss(animated: true, completion: {
101
+ self.emitEventToJS("onHide", eventData: nil)
102
+ self.isShowing = false
103
+ })
104
+ return
105
+ }
106
+
107
+ // Create Alert
108
+ let dialogMessage = UIAlertController(title: self.cancelDialogTitle, message: self.cancelDialogMessage, preferredStyle: .alert)
109
+
110
+ // Create OK button with action handler
111
+ let ok = UIAlertAction(title: self.cancelDialogConfirmText, style: .destructive, handler: { (action) -> Void in
112
+ self.emitEventToJS("onCancelTrimming", eventData: nil)
85
113
 
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
- }
114
+ vc.dismiss(animated: true, completion: {
115
+ self.emitEventToJS("onHide", eventData: nil)
116
+ self.isShowing = false
117
+ })
118
+ })
119
+
120
+ // Create Cancel button with action handlder
121
+ let cancel = UIAlertAction(title: self.cancelDialogCancelText, style: .cancel)
122
+
123
+ //Add OK and Cancel button to an Alert object
124
+ dialogMessage.addAction(ok)
125
+ dialogMessage.addAction(cancel)
126
+
127
+ // Present alert message to user
128
+ if let root = RCTPresentedViewController() {
129
+ root.present(dialogMessage, animated: true, completion: nil)
89
130
  }
90
131
  }
132
+
133
+ vc.saveBtnClicked = {(selectedRange: CMTimeRange) in
134
+ if !self.enableSaveDialog {
135
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: vc.asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds)
136
+ return
137
+ }
138
+
139
+ // Create Alert
140
+ let dialogMessage = UIAlertController(title: self.saveDialogTitle, message: self.saveDialogMessage, preferredStyle: .alert)
141
+
142
+ // Create OK button with action handler
143
+ let ok = UIAlertAction(title: self.saveDialogConfirmText, style: .default, handler: { (action) -> Void in
144
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: vc.asset.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds)
145
+ })
146
+
147
+ // Create Cancel button with action handlder
148
+ let cancel = UIAlertAction(title: self.saveDialogCancelText, style: .cancel)
149
+
150
+ //Add OK and Cancel button to an Alert object
151
+ dialogMessage.addAction(ok)
152
+ dialogMessage.addAction(cancel)
153
+
154
+ // Present alert message to user
155
+ if let root = RCTPresentedViewController() {
156
+ root.present(dialogMessage, animated: true, completion: nil)
157
+ }
158
+ }
159
+
160
+ vc.isModalInPresentation = true // prevent modal closed by swipe down
161
+
162
+ if let root = RCTPresentedViewController() {
163
+ root.present(vc, animated: true, completion: {
164
+ self.emitEventToJS("onShow", eventData: nil)
165
+ self.isShowing = true
166
+ })
167
+ }
91
168
  }
92
169
  }
93
170
  } else {
@@ -100,72 +177,7 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
100
177
  }
101
178
  }
102
179
 
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? {
180
+ private func copyFileToDocumentDir(uri: String) -> URL? {
169
181
  if let videoURL = URL(string: uri) {
170
182
  // Save the video to the document directory
171
183
  let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@@ -173,20 +185,17 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
173
185
  let fileExtension = videoURL.pathExtension
174
186
 
175
187
  // Define the filename with the correct file extension
176
- let destinationURL = documentsDirectory.appendingPathComponent("editedVideo.\(fileExtension)")
188
+ let timestamp = Int(Date().timeIntervalSince1970)
189
+ let destinationURL = documentsDirectory.appendingPathComponent("\(FILE_PREFIX)_original_\(timestamp).\(fileExtension)")
177
190
 
178
191
  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
192
  try FileManager.default.copyItem(at: videoURL, to: destinationURL)
185
193
  } catch {
194
+ print("Error while copying file to document directory \(error)")
186
195
  return nil
187
196
  }
188
197
 
189
- return destinationURL.path
198
+ return destinationURL
190
199
  } else {
191
200
  return nil
192
201
  }
@@ -199,4 +208,154 @@ class VideoTrim: RCTEventEmitter, UIVideoEditorControllerDelegate, UINavigationC
199
208
  sendEvent(withName: "VideoTrim", body: modifiedEventData)
200
209
  }
201
210
  }
211
+
212
+ @objc(listFiles:withRejecter:)
213
+ func listFiles(resolve: @escaping RCTPromiseResolveBlock,reject: @escaping RCTPromiseRejectBlock) -> Void {
214
+ let files = listFiles()
215
+ resolve(files.map{ $0.absoluteString })
216
+ }
217
+
218
+ @objc(cleanFiles:withRejecter:)
219
+ func cleanFiles(resolve: @escaping RCTPromiseResolveBlock,reject: @escaping RCTPromiseRejectBlock) -> Void {
220
+ let files = listFiles()
221
+ var successCount = 0
222
+ for file in files {
223
+ let state = deleteFile(url: file)
224
+
225
+ if state == 0 {
226
+ successCount += 1
227
+ }
228
+ }
229
+
230
+ resolve(successCount)
231
+ }
232
+
233
+ @objc(deleteFile:withResolver:withRejecter:)
234
+ func deleteFile(uri: String, resolve: @escaping RCTPromiseResolveBlock,reject: @escaping RCTPromiseRejectBlock) -> Void {
235
+ let state = deleteFile(url: URL(string: uri)!)
236
+ resolve(state == 0)
237
+ }
238
+
239
+ private func listFiles() -> [URL] {
240
+ var files: [URL] = []
241
+
242
+ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
243
+
244
+ do {
245
+ let directoryContents = try FileManager.default.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil)
246
+
247
+ for fileURL in directoryContents {
248
+ if fileURL.lastPathComponent.starts(with: FILE_PREFIX) {
249
+ files.append(fileURL)
250
+ }
251
+ }
252
+ } catch {
253
+ print("[listFiles] Error when retrieving files: \(error)")
254
+ }
255
+
256
+ return files
257
+ }
258
+
259
+ private func deleteFile(url: URL) -> Int {
260
+ do {
261
+ if FileManager.default.fileExists(atPath: url.path) {
262
+ try FileManager.default.removeItem(at: url)
263
+
264
+ return 0
265
+ }
266
+
267
+ return 1
268
+ } catch {
269
+ print("[deleteFile] Error deleting files: \(error)")
270
+
271
+ return 2
272
+ }
273
+ }
274
+
275
+ @available(iOS 13.0, *)
276
+ private func trim(viewController: VideoTrimmerViewController, inputFile: URL, videoDuration: Double, startTime: Double, endTime: Double) {
277
+ let timestamp = Int(Date().timeIntervalSince1970)
278
+ let outputName = "\(FILE_PREFIX)_\(timestamp).mp4" // use mp4 to prevent any issue with ffmpeg about file extension
279
+ let outputFile = "\(inputFile.deletingLastPathComponent().absoluteURL)\(outputName)"
280
+ let cmd = "-i \(inputFile) -ss \(startTime * 1000)ms -to \(endTime * 1000)ms -c copy \(outputFile)";
281
+
282
+ self.emitEventToJS("onStartTrimming", eventData: nil)
283
+
284
+ // Create Alert
285
+ let dialogMessage = UIAlertController(title: trimmingText, message: nil, preferredStyle: .alert)
286
+
287
+ // Present alert message to user
288
+ let progressView = UIProgressView(frame: .zero)
289
+ progressView.tintColor = .systemBlue
290
+ if let root = RCTPresentedViewController() {
291
+ root.present(dialogMessage, animated: true, completion: {
292
+ dialogMessage.view.addSubview(progressView)
293
+
294
+ progressView.translatesAutoresizingMaskIntoConstraints = false
295
+ NSLayoutConstraint.activate([
296
+ progressView.leadingAnchor.constraint(equalTo: dialogMessage.view.leadingAnchor, constant: 8),
297
+ progressView.trailingAnchor.constraint(equalTo: dialogMessage.view.trailingAnchor, constant: -8),
298
+ progressView.bottomAnchor.constraint(equalTo: dialogMessage.view.bottomAnchor, constant: -8)
299
+ ])
300
+ })
301
+ }
302
+
303
+ FFmpegKit.executeAsync(cmd, withCompleteCallback: { session in
304
+ let _ = self.deleteFile(url: inputFile) // remove the file we just copied to document directory
305
+
306
+ let state = session?.getState()
307
+
308
+ if state == .completed {
309
+ let eventPayload: [String: Any] = ["outputPath": outputFile]
310
+ self.emitEventToJS("onFinishTrimming", eventData: eventPayload)
311
+
312
+ if (self.saveToPhoto) {
313
+ PHPhotoLibrary.requestAuthorization { status in
314
+ guard status == .authorized else {
315
+ let eventPayload: [String: Any] = ["message": "Permission to access Photo Library is not granted"]
316
+ self.emitEventToJS("onError", eventData: eventPayload)
317
+ return
318
+ }
319
+
320
+ PHPhotoLibrary.shared().performChanges({
321
+ let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(string: outputFile)!)
322
+ request?.creationDate = Date()
323
+ }) { success, error in
324
+ if success {
325
+ print("Edited video saved to Photo Library successfully.")
326
+
327
+ if self.removeAfterSavedToPhoto {
328
+ let _ = self.deleteFile(url: URL(string: outputFile)!)
329
+ }
330
+ } else {
331
+ let eventPayload: [String: Any] = ["message": "Failed to save edited video to Photo Library: \(error?.localizedDescription ?? "Unknown error")"]
332
+ self.emitEventToJS("onError", eventData: eventPayload)
333
+ }
334
+ }
335
+ }
336
+ }
337
+ } else {
338
+ let eventPayload: [String: Any] = ["message": "Some error occured"]
339
+ self.emitEventToJS("onError", eventData: eventPayload)
340
+ }
341
+
342
+ DispatchQueue.main.async {
343
+ dialogMessage.dismiss(animated: false)
344
+ viewController.dismiss(animated: true, completion: {
345
+ self.emitEventToJS("onHide", eventData: nil)
346
+ self.isShowing = false
347
+ })
348
+ }
349
+ }, withLogCallback: { log in
350
+
351
+ }, withStatisticsCallback: { statistics in
352
+ let timeInMilliseconds = statistics?.getTime() ?? 0;
353
+ if timeInMilliseconds > 0 {
354
+ let completePercentage = timeInMilliseconds / (videoDuration * 1000); // from 0 -> 1
355
+ DispatchQueue.main.async {
356
+ progressView.setProgress(Float(completePercentage), animated: true)
357
+ }
358
+ }
359
+ })
360
+ }
202
361
  }