react-native-video-trim 5.0.4 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,946 @@
1
+ import React
2
+ import Photos
3
+ import ffmpegkit
4
+
5
+ let FILE_PREFIX = "trimmedVideo"
6
+ let BEFORE_TRIM_PREFIX = "beforeTrim"
7
+
8
+ @objc(VideoTrimSwift)
9
+ public class VideoTrim: NSObject, AssetLoaderDelegate, UIDocumentPickerDelegate {
10
+ // MARK: instance private props
11
+ private var isShowing = false
12
+ private var vc: VideoTrimmerViewController?
13
+ private var isVideoType = true
14
+ private var outputFile: URL?
15
+ private var editorConfig: NSDictionary?
16
+
17
+ // MARK: base options
18
+ private var saveToPhoto: Bool {
19
+ get {
20
+ return editorConfig?["saveToPhoto"] as! Bool
21
+ }
22
+ }
23
+ private var removeAfterSavedToPhoto: Bool {
24
+ get {
25
+ return editorConfig?["removeAfterSavedToPhoto"] as! Bool
26
+ }
27
+ }
28
+ private var removeAfterFailedToSavePhoto: Bool {
29
+ get {
30
+ return editorConfig?["removeAfterFailedToSavePhoto"] as! Bool
31
+ }
32
+ }
33
+ private var removeAfterSavedToDocuments: Bool {
34
+ get {
35
+ return editorConfig?["removeAfterSavedToDocuments"] as! Bool
36
+ }
37
+ }
38
+ private var removeAfterFailedToSaveDocuments: Bool {
39
+ get {
40
+ return editorConfig?["removeAfterFailedToSaveDocuments"] as! Bool
41
+ }
42
+ }
43
+ private var removeAfterShared: Bool {
44
+ get {
45
+ return editorConfig?["removeAfterShared"] as! Bool
46
+ }
47
+ }
48
+ private var removeAfterFailedToShare: Bool {
49
+ get {
50
+ return editorConfig?["removeAfterFailedToShare"] as! Bool
51
+ }
52
+ }
53
+ private var enableRotation: Bool {
54
+ get {
55
+ return editorConfig?["enableRotation"] as! Bool
56
+ }
57
+ }
58
+ private var rotationAngle: Double {
59
+ get {
60
+ return editorConfig?["rotationAngle"] as! Double
61
+ }
62
+ }
63
+
64
+ // MARK: trimming with editor options
65
+ private var trimmingText: String {
66
+ get {
67
+ return editorConfig?["trimmingText"] as! String
68
+ }
69
+ }
70
+ private var enableCancelDialog: Bool {
71
+ get {
72
+ return editorConfig?["enableCancelDialog"] as! Bool
73
+ }
74
+ }
75
+ private var cancelDialogTitle: String {
76
+ get {
77
+ return editorConfig?["cancelDialogTitle"] as! String
78
+ }
79
+ }
80
+ private var cancelDialogMessage: String {
81
+ get {
82
+ return editorConfig?["cancelDialogMessage"] as! String
83
+ }
84
+ }
85
+ private var cancelDialogCancelText: String {
86
+ get {
87
+ return editorConfig?["cancelDialogCancelText"] as! String
88
+ }
89
+ }
90
+ private var cancelDialogConfirmText: String {
91
+ get {
92
+ return editorConfig?["cancelDialogConfirmText"] as! String
93
+ }
94
+ }
95
+ private var enableSaveDialog: Bool {
96
+ get {
97
+ return editorConfig?["enableSaveDialog"] as! Bool
98
+ }
99
+ }
100
+ private var saveDialogTitle: String {
101
+ get {
102
+ return editorConfig?["saveDialogTitle"] as! String
103
+ }
104
+ }
105
+ private var saveDialogMessage: String {
106
+ get {
107
+ return editorConfig?["saveDialogMessage"] as! String
108
+ }
109
+ }
110
+ private var saveDialogCancelText: String {
111
+ get {
112
+ return editorConfig?["saveDialogCancelText"] as! String
113
+ }
114
+ }
115
+ private var saveDialogConfirmText: String {
116
+ get {
117
+ return editorConfig?["saveDialogConfirmText"] as! String
118
+ }
119
+ }
120
+ private var fullScreenModalIOS: Bool {
121
+ get {
122
+ return editorConfig?["fullScreenModalIOS"] as! Bool
123
+ }
124
+ }
125
+ private var cancelButtonText: String {
126
+ get {
127
+ return editorConfig?["cancelButtonText"] as! String
128
+ }
129
+ }
130
+ private var saveButtonText: String {
131
+ get {
132
+ return editorConfig?["saveButtonText"] as! String
133
+ }
134
+ }
135
+ private var outputExt: String {
136
+ get {
137
+ return editorConfig?["outputExt"] as! String
138
+ }
139
+ }
140
+ private var openDocumentsOnFinish: Bool {
141
+ get {
142
+ return editorConfig?["openDocumentsOnFinish"] as! Bool
143
+ }
144
+ }
145
+ private var openShareSheetOnFinish: Bool {
146
+ get {
147
+ return editorConfig?["openShareSheetOnFinish"] as! Bool
148
+ }
149
+ }
150
+ private var closeWhenFinish: Bool {
151
+ get {
152
+ return editorConfig?["closeWhenFinish"] as! Bool
153
+ }
154
+ }
155
+ private var enableCancelTrimming: Bool {
156
+ get {
157
+ return editorConfig?["enableCancelTrimming"] as! Bool
158
+ }
159
+ }
160
+
161
+ private var cancelTrimmingButtonText: String {
162
+ get {
163
+ return editorConfig?["cancelTrimmingButtonText"] as! String
164
+ }
165
+ }
166
+ private var enableCancelTrimmingDialog: Bool {
167
+ get {
168
+ return editorConfig?["enableCancelTrimmingDialog"] as! Bool
169
+ }
170
+ }
171
+ private var cancelTrimmingDialogTitle: String {
172
+ get {
173
+ return editorConfig?["cancelTrimmingDialogTitle"] as! String
174
+ }
175
+ }
176
+ private var cancelTrimmingDialogMessage: String {
177
+ get {
178
+ return editorConfig?["cancelTrimmingDialogMessage"] as! String
179
+ }
180
+ }
181
+ private var cancelTrimmingDialogCancelText: String {
182
+ get {
183
+ return editorConfig?["cancelTrimmingDialogCancelText"] as! String
184
+ }
185
+ }
186
+ private var cancelTrimmingDialogConfirmText: String {
187
+ get {
188
+ return editorConfig?["cancelTrimmingDialogConfirmText"] as! String
189
+ }
190
+ }
191
+ private var alertOnFailToLoad: Bool {
192
+ get {
193
+ return editorConfig?["alertOnFailToLoad"] as! Bool
194
+ }
195
+ }
196
+ private var alertOnFailTitle: String {
197
+ get {
198
+ return editorConfig?["alertOnFailTitle"] as! String
199
+ }
200
+ }
201
+ private var alertOnFailMessage: String {
202
+ get {
203
+ return editorConfig?["alertOnFailMessage"] as! String
204
+ }
205
+ }
206
+ private var alertOnFailCloseText: String {
207
+ get {
208
+ return editorConfig?["alertOnFailCloseText"] as! String
209
+ }
210
+ }
211
+
212
+ @objc public weak var delegate: VideoTrimProtocol?
213
+
214
+
215
+ private func emitEventToJS(_ eventName: String, eventData: [String: Any]?) {
216
+ delegate?.emitEventToJS(eventName: eventName, body: eventData)
217
+ }
218
+
219
+ private static func deleteFile(url: URL) -> Int {
220
+ do {
221
+ if FileManager.default.fileExists(atPath: url.path) {
222
+ try FileManager.default.removeItem(at: url)
223
+
224
+ return 0
225
+ }
226
+
227
+ return 1
228
+ } catch {
229
+ print("[deleteFile] Error deleting files: \(error)")
230
+
231
+ return 2
232
+ }
233
+ }
234
+
235
+ private func trim(viewController: VideoTrimmerViewController, inputFile: URL, videoDuration: Double, startTime: Double, endTime: Double) {
236
+ vc?.pausePlayer()
237
+
238
+ let timestamp = Int(Date().timeIntervalSince1970)
239
+ let outputName = "\(FILE_PREFIX)_\(timestamp).\(outputExt)"
240
+ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
241
+ outputFile = documentsDirectory.appendingPathComponent(outputName)
242
+
243
+ let formatter = DateFormatter()
244
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
245
+ formatter.timeZone = TimeZone(identifier: "UTC")
246
+ let dateTime = formatter.string(from: Date())
247
+
248
+ emitEventToJS("onStartTrimming", eventData: nil)
249
+
250
+ var ffmpegSession: FFmpegSession?
251
+ let progressAlert = ProgressAlertController()
252
+ progressAlert.modalPresentationStyle = .overFullScreen
253
+ progressAlert.modalTransitionStyle = .crossDissolve
254
+ progressAlert.setTitle(trimmingText)
255
+
256
+ if enableCancelTrimming {
257
+ progressAlert.setCancelTitle(cancelTrimmingButtonText)
258
+ progressAlert.showCancelBtn()
259
+ progressAlert.onDismiss = {
260
+ if self.enableCancelTrimmingDialog {
261
+ let dialogMessage = UIAlertController(title: self.cancelTrimmingDialogTitle, message: self.cancelTrimmingDialogMessage, preferredStyle: .alert)
262
+ dialogMessage.overrideUserInterfaceStyle = .dark
263
+
264
+ // Create OK button with action handler
265
+ let ok = UIAlertAction(title: self.cancelDialogConfirmText, style: .destructive, handler: { (action) -> Void in
266
+
267
+ if let ffmpegSession = ffmpegSession {
268
+ ffmpegSession.cancel()
269
+ } else {
270
+ self.emitEventToJS("onCancelTrimming", eventData: nil)
271
+ }
272
+
273
+ progressAlert.dismiss(animated: true)
274
+ })
275
+
276
+ // Create Cancel button with action handlder
277
+ let cancel = UIAlertAction(title: self.cancelDialogCancelText, style: .cancel)
278
+
279
+ //Add OK and Cancel button to an Alert object
280
+ dialogMessage.addAction(ok)
281
+ dialogMessage.addAction(cancel)
282
+
283
+ // Present alert message to user
284
+ if let root = RCTPresentedViewController() {
285
+ root.present(dialogMessage, animated: true, completion: nil)
286
+ }
287
+ } else {
288
+ if let ffmpegSession = ffmpegSession {
289
+ ffmpegSession.cancel()
290
+ } else {
291
+ self.emitEventToJS("onCancelTrimming", eventData: nil)
292
+ }
293
+
294
+ progressAlert.dismiss(animated: true)
295
+ }
296
+
297
+ }
298
+ }
299
+
300
+ if let root = RCTPresentedViewController() {
301
+ root.present(progressAlert, animated: true, completion: nil)
302
+ }
303
+
304
+ var cmds = [
305
+ "-ss",
306
+ "\(startTime * 1000)ms",
307
+ "-to",
308
+ "\(endTime * 1000)ms",
309
+ ]
310
+
311
+ if enableRotation {
312
+ cmds.append(contentsOf: ["-display_rotation", "\(rotationAngle)"])
313
+ }
314
+
315
+ cmds.append(contentsOf: [
316
+ "-i",
317
+ "\(inputFile)",
318
+ "-c",
319
+ "copy",
320
+ "-metadata",
321
+ "creation_time=\(dateTime)",
322
+ outputFile!.absoluteString
323
+ ])
324
+
325
+ print("Command: ", cmds.joined(separator: " "))
326
+
327
+ let eventPayload: [String: Any] = [
328
+ "message": "Command: \(cmds.joined(separator: " "))"
329
+ ]
330
+ self.emitEventToJS("onLog", eventData: eventPayload)
331
+
332
+ ffmpegSession = FFmpegKit.execute(withArgumentsAsync: cmds, withCompleteCallback: { session in
333
+
334
+ // always hide progressAlert
335
+ DispatchQueue.main.async {
336
+ progressAlert.dismiss(animated: true)
337
+ }
338
+
339
+ let state = session?.getState()
340
+ let returnCode = session?.getReturnCode()
341
+
342
+ if ReturnCode.isSuccess(returnCode) {
343
+ let eventPayload: [String: Any] = ["outputPath": self.outputFile!.absoluteString, "startTime": (startTime * 1000).rounded(), "endTime": (endTime * 1000).rounded(), "duration": (videoDuration * 1000).rounded()]
344
+ self.emitEventToJS("onFinishTrimming", eventData: eventPayload)
345
+
346
+ if (self.saveToPhoto && self.isVideoType) {
347
+ PHPhotoLibrary.requestAuthorization { status in
348
+ guard status == .authorized else {
349
+ self.onError(message: "Permission to access Photo Library is not granted", code: .noPhotoPermission)
350
+ return
351
+ }
352
+
353
+ PHPhotoLibrary.shared().performChanges({
354
+ let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: self.outputFile!)
355
+ request?.creationDate = Date()
356
+ }) { success, error in
357
+ if success {
358
+ print("Edited video saved to Photo Library successfully.")
359
+
360
+ if self.removeAfterSavedToPhoto {
361
+ let _ = VideoTrim.deleteFile(url: self.outputFile!)
362
+ }
363
+ } else {
364
+ self.onError(message: "Failed to save edited video to Photo Library: \(error?.localizedDescription ?? "Unknown error")", code: .failToSaveToPhoto)
365
+ if self.removeAfterFailedToSavePhoto {
366
+ let _ = VideoTrim.deleteFile(url: self.outputFile!)
367
+ }
368
+ }
369
+ }
370
+ }
371
+ } else if self.openDocumentsOnFinish {
372
+ self.saveFileToFilesApp(fileURL: self.outputFile!)
373
+
374
+ // must return otherwise editor will close
375
+ return
376
+ } else if self.openShareSheetOnFinish {
377
+ self.shareFile(fileURL: self.outputFile!)
378
+
379
+ // must return otherwise editor will close
380
+ return
381
+ }
382
+
383
+ if self.closeWhenFinish {
384
+ self.closeEditor()
385
+ }
386
+
387
+ } else if ReturnCode.isCancel(returnCode) {
388
+ // CANCEL
389
+ self.emitEventToJS("onCancelTrimming", eventData: nil)
390
+ } else {
391
+ // FAILURE
392
+ self.onError(message: "Command failed with state \(String(describing: FFmpegKitConfig.sessionState(toString: state ?? .failed))) and rc \(String(describing: returnCode)).\(String(describing: session?.getFailStackTrace()))", code: .trimmingFailed)
393
+ if self.closeWhenFinish {
394
+ self.closeEditor()
395
+ }
396
+ }
397
+
398
+
399
+ }, withLogCallback: { log in
400
+ guard let log = log else { return }
401
+
402
+ print("FFmpeg process started with log " + (log.getMessage()));
403
+
404
+ let eventPayload: [String: Any] = [
405
+ "level": log.getLevel(),
406
+ "message": log.getMessage() ?? "",
407
+ "sessionId": log.getSessionId(),
408
+ ]
409
+ self.emitEventToJS("onLog", eventData: eventPayload)
410
+
411
+ }, withStatisticsCallback: { statistics in
412
+ guard let statistics = statistics else { return }
413
+
414
+ let timeInMilliseconds = statistics.getTime()
415
+ if timeInMilliseconds > 0 {
416
+ let completePercentage = timeInMilliseconds / (videoDuration * 1000); // from 0 -> 1
417
+ DispatchQueue.main.async {
418
+ progressAlert.setProgress(Float(completePercentage))
419
+ }
420
+ }
421
+
422
+ let eventPayload: [String: Any] = [
423
+ "sessionId": statistics.getSessionId(),
424
+ "videoFrameNumber": statistics.getVideoFrameNumber(),
425
+ "videoFps": statistics.getVideoFps(),
426
+ "videoQuality": statistics.getVideoQuality(),
427
+ "size": statistics.getSize(),
428
+ "time": statistics.getTime(),
429
+ "bitrate": statistics.getBitrate(),
430
+ "speed": statistics.getSpeed()
431
+ ]
432
+ self.emitEventToJS("onStatistics", eventData: eventPayload)
433
+ })
434
+ }
435
+
436
+ @objc(trim:url:config:)
437
+ public func _trim(inputFile: String, config: NSDictionary, completion: @escaping ([String: Any]?) -> Void) {
438
+ var destPath: URL?
439
+
440
+ if inputFile.hasPrefix("http://") || inputFile.hasPrefix("https://") {
441
+ destPath = URL(string: inputFile)
442
+ } else {
443
+ destPath = renameFile(at: URL(string: inputFile)!, newName: BEFORE_TRIM_PREFIX)
444
+ }
445
+
446
+ guard let destPath = destPath else {
447
+ completion(nil)
448
+ return
449
+ }
450
+
451
+ let timestamp = Int(Date().timeIntervalSince1970)
452
+ let outputExt = config["outputExt"] as? String ?? "mp4"
453
+ let outputName = "\(FILE_PREFIX)_\(timestamp).\(outputExt)"
454
+ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
455
+ let outputFile = documentsDirectory.appendingPathComponent(outputName)
456
+
457
+ let formatter = DateFormatter()
458
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
459
+ formatter.timeZone = TimeZone(identifier: "UTC")
460
+ let dateTime = formatter.string(from: Date())
461
+
462
+ let startTime = config["startTime"] as? Double ?? 0
463
+ let endTime = config["endTime"] as? Double ?? 0
464
+ var cmds = [
465
+ "-ss",
466
+ "\(startTime)ms",
467
+ "-to",
468
+ "\(endTime)ms",
469
+ ]
470
+
471
+ if let enableRotation = config["enableRotation"] as? Bool, enableRotation {
472
+ let rotationAngle = config["rotationAngle"] as? Double ?? 0
473
+ cmds.append(contentsOf: ["-display_rotation", "\(rotationAngle)"])
474
+ }
475
+
476
+ cmds.append(contentsOf: [
477
+ "-i",
478
+ "\(destPath.absoluteString)",
479
+ "-c",
480
+ "copy",
481
+ "-metadata",
482
+ "creation_time=\(dateTime)",
483
+ outputFile.absoluteString
484
+ ])
485
+
486
+ print("Command: ", cmds.joined(separator: " "))
487
+
488
+ FFmpegKit.execute(withArgumentsAsync: cmds, withCompleteCallback: { session in
489
+ let returnCode = session?.getReturnCode()
490
+
491
+ if ReturnCode.isSuccess(returnCode) {
492
+ let result = ["outputPath": outputFile.absoluteString, "startTime": startTime, "endTime": endTime] as [String : Any]
493
+
494
+ completion(result)
495
+ } else if ReturnCode.isCancel(returnCode) {
496
+ // CANCEL
497
+ completion(nil)
498
+ } else {
499
+ // FAILURE
500
+ completion(nil)
501
+ }
502
+ }, withLogCallback: nil, withStatisticsCallback: nil)
503
+ }
504
+
505
+
506
+
507
+ private func saveFileToFilesApp(fileURL: URL) {
508
+ DispatchQueue.main.async {
509
+ let documentPicker = UIDocumentPickerViewController(url: fileURL, in: .exportToService)
510
+ documentPicker.delegate = self
511
+ documentPicker.modalPresentationStyle = .formSheet
512
+ if let root = RCTPresentedViewController() {
513
+ root.present(documentPicker, animated: true, completion: nil)
514
+ }
515
+ }
516
+ }
517
+
518
+ private func shareFile(fileURL: URL) {
519
+ DispatchQueue.main.async {
520
+ // Create an instance of UIActivityViewController
521
+ let activityViewController = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
522
+
523
+ activityViewController.completionWithItemsHandler = { activityType, completed, returnedItems, error in
524
+
525
+ if let error = error {
526
+ let message = "Sharing error: \(error.localizedDescription)"
527
+ print(message)
528
+ self.onError(message: message, code: .failToShare)
529
+
530
+ if self.removeAfterFailedToShare {
531
+ let _ = VideoTrim.deleteFile(url: self.outputFile!)
532
+ }
533
+ return
534
+ }
535
+
536
+ if completed {
537
+ print("User completed the sharing activity")
538
+ if self.removeAfterShared {
539
+ let _ = VideoTrim.deleteFile(url: self.outputFile!)
540
+ }
541
+ } else {
542
+ print("User cancelled or failed to complete the sharing activity")
543
+ if self.removeAfterFailedToShare {
544
+ let _ = VideoTrim.deleteFile(url: self.outputFile!)
545
+ }
546
+ }
547
+
548
+ self.closeEditor()
549
+
550
+ }
551
+
552
+ // Present the share sheet
553
+ if let root = RCTPresentedViewController() {
554
+ root.present(activityViewController, animated: true, completion: nil)
555
+ }
556
+ }
557
+
558
+ }
559
+
560
+ private func onError(message: String, code: ErrorCode) {
561
+ let eventPayload: [String: String] = [
562
+ "message": message,
563
+ "errorCode": code.rawValue
564
+ ]
565
+ self.emitEventToJS("onError", eventData: eventPayload)
566
+ }
567
+
568
+ private func renameFile(at url: URL, newName: String) -> URL? {
569
+ let fileManager = FileManager.default
570
+
571
+ // Get the directory of the existing file
572
+ let directory = url.deletingLastPathComponent()
573
+
574
+ // Get the file extension
575
+ let fileExtension = url.pathExtension
576
+
577
+ // Create the new file URL with the new name and the same extension
578
+ let newFileURL = directory.appendingPathComponent(newName).appendingPathExtension(fileExtension)
579
+
580
+ // Check if a file with the new name already exists
581
+ if fileManager.fileExists(atPath: newFileURL.path) {
582
+ do {
583
+ // If the file exists, remove it first to avoid conflicts
584
+ try fileManager.removeItem(at: newFileURL)
585
+ } catch {
586
+ print("Error removing existing file: \(error)")
587
+ return nil
588
+ }
589
+ }
590
+
591
+ do {
592
+ // Rename (move) the file
593
+ try fileManager.moveItem(at: url, to: newFileURL)
594
+ print("File renamed successfully to \(newFileURL.absoluteString)")
595
+ return newFileURL
596
+ } catch {
597
+ print("Error renaming file: \(error)")
598
+ return nil
599
+ }
600
+ }
601
+ }
602
+
603
+ // MARK: @objc instance methods
604
+ extension VideoTrim {
605
+ @objc(showEditor:withConfig:)
606
+ public func showEditor(uri: String, config: NSDictionary) {
607
+ if isShowing {
608
+ return
609
+ }
610
+ editorConfig = config
611
+
612
+ //
613
+ // saveToPhoto = config["saveToPhoto"] as? Bool ?? false
614
+ //
615
+ // removeAfterSavedToPhoto = config["removeAfterSavedToPhoto"] as? Bool ?? false
616
+ // removeAfterFailedToSavePhoto = config["removeAfterFailedToSavePhoto"] as? Bool ?? false
617
+ // removeAfterSavedToDocuments = config["removeAfterSavedToDocuments"] as? Bool ?? false
618
+ // removeAfterFailedToSaveDocuments = config["removeAfterFailedToSaveDocuments"] as? Bool ?? false
619
+ // removeAfterShared = config["removeAfterShared"] as? Bool ?? false
620
+ // removeAfterFailedToShare = config["removeAfterFailedToShare"] as? Bool ?? false
621
+ //
622
+ // enableCancelDialog = config["enableCancelDialog"] as? Bool ?? true
623
+ // cancelDialogTitle = config["cancelDialogTitle"] as? String ?? "Warning!"
624
+ // cancelDialogMessage = config["cancelDialogMessage"] as? String ?? "Are you sure want to cancel?"
625
+ // cancelDialogCancelText = config["cancelDialogCancelText"] as? String ?? "Close"
626
+ // cancelDialogConfirmText = config["cancelDialogConfirmText"] as? String ?? "Proceed"
627
+ //
628
+ // enableSaveDialog = config["enableSaveDialog"] as? Bool ?? true
629
+ // saveDialogTitle = config["saveDialogTitle"] as? String ?? "Confirmation!"
630
+ // saveDialogMessage = config["saveDialogMessage"] as? String ?? "Are you sure want to save?"
631
+ // saveDialogCancelText = config["saveDialogCancelText"] as? String ?? "Close"
632
+ // saveDialogConfirmText = config["saveDialogConfirmText"] as? String ?? "Proceed"
633
+ // trimmingText = config["trimmingText"] as? String ?? "Trimming video..."
634
+ // fullScreenModalIOS = config["fullScreenModalIOS"] as? Bool ?? false
635
+ // isVideoType = (config["type"] as? String ?? "video") == "video"
636
+ // outputExt = config["outputExt"] as? String ?? "mp4"
637
+ // openDocumentsOnFinish = config["openDocumentsOnFinish"] as? Bool ?? false
638
+ // openShareSheetOnFinish = config["openShareSheetOnFinish"] as? Bool ?? false
639
+ //
640
+ // closeWhenFinish = config["closeWhenFinish"] as? Bool ?? true
641
+ // enableCancelTrimming = config["enableCancelTrimming"] as? Bool ?? true
642
+ // cancelTrimmingButtonText = config["cancelTrimmingButtonText"] as? String ?? "Cancel"
643
+ // enableCancelTrimmingDialog = config["enableCancelTrimmingDialog"] as? Bool ?? true
644
+ // cancelTrimmingDialogTitle = config["cancelTrimmingDialogTitle"] as? String ?? "Warning!"
645
+ // cancelTrimmingDialogMessage = config["cancelTrimmingDialogMessage"] as? String ?? "Are you sure want to cancel trimming?"
646
+ // cancelTrimmingDialogCancelText = config["cancelTrimmingDialogCancelText"] as? String ?? "Close"
647
+ // cancelTrimmingDialogConfirmText = config["cancelTrimmingDialogConfirmText"] as? String ?? "Proceed"
648
+ // alertOnFailToLoad = config["alertOnFailToLoad"] as? Bool ?? true
649
+ // alertOnFailTitle = config["alertOnFailTitle"] as? String ?? "Error"
650
+ // alertOnFailMessage = config["alertOnFailMessage"] as? String ?? "Fail to load media. Possibly invalid file or no network connection"
651
+ // alertOnFailCloseText = config["alertOnFailCloseText"] as? String ?? "Close"
652
+ //
653
+ // if let cancelBtnText = config["cancelButtonText"] as? String, !cancelBtnText.isEmpty {
654
+ // self.cancelButtonText = cancelBtnText
655
+ // }
656
+ //
657
+ // if let saveButtonText = config["saveButtonText"] as? String, !saveButtonText.isEmpty {
658
+ // self.saveButtonText = saveButtonText
659
+ // }
660
+
661
+ var destPath: URL?
662
+
663
+ if uri.hasPrefix("http://") || uri.hasPrefix("https://") {
664
+ destPath = URL(string: uri)
665
+ } else {
666
+ destPath = renameFile(at: URL(string: uri)!, newName: BEFORE_TRIM_PREFIX)
667
+ }
668
+
669
+ guard let destPath = destPath else { return }
670
+
671
+ DispatchQueue.main.async {
672
+ self.vc = VideoTrimmerViewController()
673
+
674
+ guard let vc = self.vc else { return }
675
+
676
+ vc.configure(config: config)
677
+
678
+ vc.cancelBtnClicked = {
679
+ if !self.enableCancelDialog {
680
+ self.emitEventToJS("onCancel", eventData: nil)
681
+
682
+ vc.dismiss(animated: true, completion: {
683
+ self.emitEventToJS("onHide", eventData: nil)
684
+ self.isShowing = false
685
+ })
686
+ return
687
+ }
688
+
689
+ // Create Alert
690
+ let dialogMessage = UIAlertController(title: self.cancelDialogTitle, message: self.cancelDialogMessage, preferredStyle: .alert)
691
+ dialogMessage.overrideUserInterfaceStyle = .dark
692
+
693
+ // Create OK button with action handler
694
+ let ok = UIAlertAction(title: self.cancelDialogConfirmText, style: .destructive, handler: { (action) -> Void in
695
+ self.emitEventToJS("onCancel", eventData: nil)
696
+
697
+ vc.dismiss(animated: true, completion: {
698
+ self.emitEventToJS("onHide", eventData: nil)
699
+ self.isShowing = false
700
+ })
701
+ })
702
+
703
+ // Create Cancel button with action handlder
704
+ let cancel = UIAlertAction(title: self.cancelDialogCancelText, style: .cancel)
705
+
706
+ //Add OK and Cancel button to an Alert object
707
+ dialogMessage.addAction(ok)
708
+ dialogMessage.addAction(cancel)
709
+
710
+ // Present alert message to user
711
+ if let root = RCTPresentedViewController() {
712
+ root.present(dialogMessage, animated: true, completion: nil)
713
+ }
714
+ }
715
+
716
+ vc.saveBtnClicked = {(selectedRange: CMTimeRange) in
717
+ if !self.enableSaveDialog {
718
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: self.vc!.asset!.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds)
719
+ return
720
+ }
721
+
722
+ // Create Alert
723
+ let dialogMessage = UIAlertController(title: self.saveDialogTitle, message: self.saveDialogMessage, preferredStyle: .alert)
724
+ dialogMessage.overrideUserInterfaceStyle = .dark
725
+
726
+ // Create OK button with action handler
727
+ let ok = UIAlertAction(title: self.saveDialogConfirmText, style: .default, handler: { (action) -> Void in
728
+ self.trim(viewController: vc,inputFile: destPath, videoDuration: vc.asset!.duration.seconds, startTime: selectedRange.start.seconds, endTime: selectedRange.end.seconds)
729
+ })
730
+
731
+ // Create Cancel button with action handlder
732
+ let cancel = UIAlertAction(title: self.saveDialogCancelText, style: .cancel)
733
+
734
+ //Add OK and Cancel button to an Alert object
735
+ dialogMessage.addAction(ok)
736
+ dialogMessage.addAction(cancel)
737
+
738
+ // Present alert message to user
739
+ if let root = RCTPresentedViewController() {
740
+ root.present(dialogMessage, animated: true, completion: nil)
741
+ }
742
+ }
743
+
744
+ vc.isModalInPresentation = true // prevent modal closed by swipe down
745
+
746
+ if self.fullScreenModalIOS {
747
+ vc.modalPresentationStyle = .fullScreen
748
+ }
749
+
750
+ if let root = RCTPresentedViewController() {
751
+ root.present(vc, animated: true, completion: {
752
+ self.emitEventToJS("onShow", eventData: nil)
753
+ self.isShowing = true
754
+
755
+ // start loading asset after view is finished presenting
756
+ // otherwise it may run too fast for local file and autoplay looks weird
757
+ let assetLoader = AssetLoader()
758
+ assetLoader.delegate = self
759
+ assetLoader.loadAsset(url: destPath, isVideoType: self.isVideoType)
760
+ })
761
+ }
762
+ }
763
+
764
+ }
765
+
766
+ @objc(closeEditor)
767
+ public func closeEditor() {
768
+ guard let vc = vc else { return }
769
+ // some how in case we trim a very short video the view controller is still visible after first .dismiss call
770
+ // even the file is successfully saved
771
+ // that's why we need a small delay here to ensure vc will be dismissed
772
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
773
+ vc.dismiss(animated: true, completion: {
774
+ self.emitEventToJS("onHide", eventData: nil)
775
+ self.isShowing = false
776
+ })
777
+ }
778
+ }
779
+ }
780
+
781
+ // MARK: @objc static methods
782
+ extension VideoTrim {
783
+ @objc(listFiles)
784
+ public static func _listFiles() -> [String] {
785
+ return listFiles().map{ $0.absoluteString }
786
+ }
787
+
788
+ @objc(cleanFiles)
789
+ public static func cleanFiles() -> Int {
790
+ let files = listFiles()
791
+ var successCount = 0
792
+ for file in files {
793
+ let state = deleteFile(url: file)
794
+
795
+ if state == 0 {
796
+ successCount += 1
797
+ }
798
+ }
799
+
800
+ return successCount
801
+ }
802
+
803
+ @objc(deleteFile:)
804
+ public static func deleteFile(uri: String) -> Bool {
805
+ let state = deleteFile(url: URL(string: uri)!)
806
+ return state == 0
807
+ }
808
+
809
+ private static func listFiles() -> [URL] {
810
+ var files: [URL] = []
811
+
812
+ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
813
+
814
+ do {
815
+ let directoryContents = try FileManager.default.contentsOfDirectory(at: documentsDirectory, includingPropertiesForKeys: nil)
816
+
817
+ for fileURL in directoryContents {
818
+ if fileURL.lastPathComponent.starts(with: FILE_PREFIX) || fileURL.lastPathComponent.starts(with: BEFORE_TRIM_PREFIX) {
819
+ files.append(fileURL)
820
+ }
821
+ }
822
+ } catch {
823
+ print("[listFiles] Error when retrieving files: \(error)")
824
+ }
825
+
826
+ return files
827
+ }
828
+
829
+ @objc(isValidFile:url:)
830
+ public static func isValidFile(url: String, completion: @escaping ([String: Any]) -> Void) -> Void {
831
+ let fileURL = URL(string: url)!
832
+ checkFileValidity(url: fileURL) { isValid, fileType, duration in
833
+ if isValid {
834
+ print("Valid \(fileType) file with duration: \(duration) milliseconds")
835
+ } else {
836
+ print("Invalid file")
837
+ }
838
+
839
+ let payload: [String: Any] = [
840
+ "isValid": isValid,
841
+ "fileType": fileType,
842
+ "duration": duration
843
+ ]
844
+
845
+ completion(payload)
846
+ }
847
+ }
848
+
849
+ private static func checkFileValidity(url: URL, completion: @escaping (Bool, String, Double) -> Void) {
850
+ let asset = AVAsset(url: url)
851
+
852
+ // Load the duration and tracks asynchronously
853
+ asset.loadValuesAsynchronously(forKeys: ["duration", "tracks"]) {
854
+ var error: NSError? = nil
855
+
856
+ // Check if the duration and tracks are loaded
857
+ let durationStatus = asset.statusOfValue(forKey: "duration", error: &error)
858
+ let tracksStatus = asset.statusOfValue(forKey: "tracks", error: &error)
859
+
860
+ // Ensure both properties are loaded successfully
861
+ guard durationStatus == .loaded, tracksStatus == .loaded, error == nil else {
862
+ DispatchQueue.main.async {
863
+ completion(false, "unknown", -1)
864
+ }
865
+ return
866
+ }
867
+
868
+ // Check if the asset contains any video or audio tracks
869
+ let videoTracks = asset.tracks(withMediaType: .video)
870
+ let audioTracks = asset.tracks(withMediaType: .audio)
871
+
872
+ let isValid = !videoTracks.isEmpty || !audioTracks.isEmpty
873
+ let fileType: String
874
+ if !videoTracks.isEmpty {
875
+ fileType = "video"
876
+ } else if !audioTracks.isEmpty {
877
+ fileType = "audio"
878
+ } else {
879
+ fileType = "unknown"
880
+ }
881
+
882
+ let duration = CMTimeGetSeconds(asset.duration) * 1000
883
+
884
+ // DispatchQueue.main.async {
885
+ completion(isValid, fileType, isValid ? duration.rounded() : -1)
886
+ // }
887
+ }
888
+ }
889
+ }
890
+
891
+ // MARK: Delegates
892
+ // MARK: AssetLoader delegate
893
+ extension VideoTrim {
894
+ func assetLoader(_ loader: AssetLoader, didFailWithError error: any Error, forKey key: String) {
895
+ let message = "Failed to load \(key): \(error.localizedDescription)"
896
+ print("Failed to load \(key)", message)
897
+
898
+ self.onError(message: message, code: .failToLoadMedia)
899
+ vc?.onAssetFailToLoad()
900
+
901
+ if alertOnFailToLoad {
902
+ let dialogMessage = UIAlertController(title: alertOnFailTitle, message: alertOnFailMessage, preferredStyle: .alert)
903
+ dialogMessage.overrideUserInterfaceStyle = .dark
904
+
905
+ // Create Cancel button with action handlder
906
+ let ok = UIAlertAction(title: alertOnFailCloseText, style: .default)
907
+
908
+ //Add OK and Cancel button to an Alert object
909
+ dialogMessage.addAction(ok)
910
+
911
+ // Present alert message to user
912
+ if let root = RCTPresentedViewController() {
913
+ root.present(dialogMessage, animated: true, completion: nil)
914
+ }
915
+ }
916
+ }
917
+
918
+ func assetLoaderDidSucceed(_ loader: AssetLoader) {
919
+ print("Asset loaded successfully")
920
+
921
+ vc?.asset = loader.asset
922
+
923
+ let eventPayload: [String: Any] = [
924
+ "duration": loader.asset!.duration.seconds * 1000,
925
+ ]
926
+ self.emitEventToJS("onLoad", eventData: eventPayload)
927
+ }
928
+ }
929
+
930
+
931
+ // MARK: DocumentPicker delegate
932
+ extension VideoTrim {
933
+ public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
934
+ if removeAfterSavedToDocuments {
935
+ let _ = VideoTrim.deleteFile(url: outputFile!)
936
+ }
937
+ closeEditor()
938
+ }
939
+
940
+ public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
941
+ if removeAfterFailedToSaveDocuments {
942
+ let _ = VideoTrim.deleteFile(url: outputFile!)
943
+ }
944
+ closeEditor()
945
+ }
946
+ }