react-native-video-trim 1.0.20 → 1.0.22

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,41 +1,23 @@
1
- //
2
- // VideoTrimmerViewController.swift
3
- // react-native-video-trim
4
- //
5
- // Created by Duc Trung Mai on 17/1/24.
6
- //
7
-
8
1
  import UIKit
9
2
  import AVKit
10
3
 
11
4
  extension CMTime {
12
5
  var displayString: String {
13
6
  let offset = TimeInterval(seconds)
14
- let numberOfNanosecondsFloat = (offset - TimeInterval(Int(offset))) * 1000.0
7
+ let numberOfNanosecondsFloat = (offset - TimeInterval(Int(offset))) * 100.0
15
8
  let nanoseconds = Int(numberOfNanosecondsFloat)
9
+
10
+ let formatter = CMTime.dateFormatter
11
+ return String(format: "%@.%02d", formatter.string(from: offset) ?? "00:00", nanoseconds)
12
+ }
13
+
14
+ private static var dateFormatter: DateComponentsFormatter = {
16
15
  let formatter = DateComponentsFormatter()
17
16
  formatter.unitsStyle = .positional
18
17
  formatter.zeroFormattingBehavior = .pad
19
18
  formatter.allowedUnits = [.minute, .second]
20
- return String(format: "%@.%03d", formatter.string(from: offset) ?? "00:00", nanoseconds)
21
- }
22
- }
23
-
24
- extension AVAsset {
25
- var fullRange: CMTimeRange {
26
- return CMTimeRange(start: .zero, duration: duration)
27
- }
28
- func trimmedComposition(_ range: CMTimeRange) -> AVAsset {
29
- guard CMTimeRangeEqual(fullRange, range) == false else {return self}
30
-
31
- let composition = AVMutableComposition()
32
- try? composition.insertTimeRange(range, of: self, at: .zero)
33
-
34
- if let videoTrack = tracks(withMediaType: .video).first {
35
- composition.tracks.forEach {$0.preferredTransform = videoTrack.preferredTransform}
36
- }
37
- return composition
38
- }
19
+ return formatter
20
+ }()
39
21
  }
40
22
 
41
23
  @available(iOS 13.0, *)
@@ -48,69 +30,61 @@ class VideoTrimmerViewController: UIViewController {
48
30
  var cancelBtnClicked: (() -> Void)?
49
31
  var saveBtnClicked: ((CMTimeRange) -> Void)?
50
32
 
51
- let playerController = AVPlayerViewController()
52
- var trimmer: VideoTrimmer!
53
- var timingStackView: UIStackView!
54
- var leadingTrimLabel: UILabel!
55
- var currentTimeLabel: UILabel!
56
- var trailingTrimLabel: UILabel!
57
-
33
+ private let playerController = AVPlayerViewController()
34
+ private var trimmer: VideoTrimmer!
35
+ private var timingStackView: UIStackView!
36
+ private var leadingTrimLabel: UILabel!
37
+ private var currentTimeLabel: UILabel!
38
+ private var trailingTrimLabel: UILabel!
58
39
  private var btnStackView: UIStackView!
59
40
  private var cancelBtn: UIButton!
60
41
  private var playBtn: UIButton!
61
42
  private var saveBtn: UIButton!
62
43
  private let playIcon = UIImage(systemName: "play.fill")
63
44
  private let pauseIcon = UIImage(systemName: "pause.fill")
64
-
65
- private var wasPlaying = false
66
- private var player: AVPlayer! {playerController.player}
45
+ private var player: AVPlayer! { playerController.player }
67
46
  private var timeObserverToken: Any?
68
47
 
48
+ var isSeekInProgress: Bool = false // Marker
49
+ private var chaseTime = CMTime.zero
50
+ private var preferredFrameRate: Float = 23.98
51
+
69
52
 
70
53
  // MARK: - Input
71
- @objc private func didBeginTrimming(_ sender: VideoTrimmer) {
72
- updateLabels()
73
-
74
- wasPlaying = (player.timeControlStatus != .paused)
75
- player.pause()
76
-
77
- updatePlayerAsset()
54
+ @objc private func didBeginTrimmingFromStart(_ sender: VideoTrimmer) {
55
+ handleBeforeProgressChange()
78
56
  }
79
57
 
80
- @objc private func didEndTrimming(_ sender: VideoTrimmer) {
81
- updateLabels()
82
-
83
- if wasPlaying == true {
84
- player.play()
85
- }
86
-
87
- updatePlayerAsset()
58
+ @objc private func leadingGrabberChanged(_ sender: VideoTrimmer) {
59
+ handleProgressChanged(time: trimmer.selectedRange.start)
88
60
  }
89
61
 
90
- @objc private func selectedRangeDidChanged(_ sender: VideoTrimmer) {
91
- updateLabels()
62
+ @objc private func didEndTrimmingFromStart(_ sender: VideoTrimmer) {
63
+ handleTrimmingEnd(true)
64
+ }
65
+
66
+ @objc private func didBeginTrimmingFromEnd(_ sender: VideoTrimmer) {
67
+ handleBeforeProgressChange()
68
+ }
69
+
70
+ @objc private func trailingGrabberChanged(_ sender: VideoTrimmer) {
71
+ handleProgressChanged(time: trimmer.selectedRange.end)
72
+ }
73
+
74
+ @objc private func didEndTrimmingFromEnd(_ sender: VideoTrimmer) {
75
+ handleTrimmingEnd(false)
92
76
  }
93
77
 
94
78
  @objc private func didBeginScrubbing(_ sender: VideoTrimmer) {
95
- updateLabels()
96
-
97
- wasPlaying = (player.timeControlStatus != .paused)
98
- player.pause()
79
+ handleBeforeProgressChange()
99
80
  }
100
81
 
101
82
  @objc private func didEndScrubbing(_ sender: VideoTrimmer) {
102
83
  updateLabels()
103
-
104
- if wasPlaying == true {
105
- player.play()
106
- }
107
84
  }
108
85
 
109
86
  @objc private func progressDidChanged(_ sender: VideoTrimmer) {
110
- updateLabels()
111
-
112
- let time = CMTimeSubtract(trimmer.progress, trimmer.selectedRange.start)
113
- player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
87
+ handleProgressChanged(time: trimmer.progress)
114
88
  }
115
89
 
116
90
  // MARK: - Private
@@ -120,38 +94,81 @@ class VideoTrimmerViewController: UIViewController {
120
94
  trailingTrimLabel.text = trimmer.selectedRange.end.displayString
121
95
  }
122
96
 
123
- private func updatePlayerAsset() {
124
- let outputRange = trimmer.trimmingState == .none ? trimmer.selectedRange : asset.fullRange
125
- let trimmedAsset = asset.trimmedComposition(outputRange)
126
- if trimmedAsset != player.currentItem?.asset {
127
- player.replaceCurrentItem(with: AVPlayerItem(asset: trimmedAsset))
128
- }
97
+ private func handleBeforeProgressChange() {
98
+ updateLabels()
99
+ player.pause()
100
+ setPlayBtnIcon()
101
+ }
102
+
103
+ private func handleProgressChanged(time: CMTime) {
104
+ updateLabels()
105
+ seek(to: time)
106
+ }
107
+
108
+ private func handleTrimmingEnd(_ start: Bool) {
109
+ self.trimmer.progress = start ? trimmer.selectedRange.start : trimmer.selectedRange.end
110
+ updateLabels()
111
+ player.seek(to: trimmer.progress, toleranceBefore: .zero, toleranceAfter: .zero)
129
112
  }
130
113
 
131
114
  // MARK: - UIViewController
132
115
  override func viewDidLoad() {
133
116
  super.viewDidLoad()
134
117
 
135
- view.backgroundColor = .black
136
-
137
- // bottom action buttons
138
- cancelBtn = UIButton(type: .system)
139
- cancelBtn.setTitle(cancelBtnText, for: .normal)
140
- cancelBtn.titleLabel?.font = .systemFont(ofSize: 18)
141
- cancelBtn.setTitleColor(.white, for: .normal)
142
- cancelBtn.addTarget(self, action: #selector(onCancelBtnClicked), for: .touchUpInside)
118
+ setupView()
119
+ setupButtons()
120
+ setupTimeLabels()
121
+ setupVideoTrimmer()
122
+ setupPlayerController()
123
+ setupTimeObserver()
143
124
 
125
+ updateLabels()
126
+ }
127
+
128
+ override func viewWillDisappear(_ animated: Bool) {
129
+ super.viewWillDisappear(animated)
144
130
 
145
- playBtn = UIButton(type: .system)
146
- playBtn.setImage(playIcon, for: .normal)
147
- playBtn.tintColor = .systemBlue
148
- playBtn.addTarget(self, action: #selector(togglePlay(sender:)), for: .touchUpInside)
131
+ player.pause()
132
+ if let token = timeObserverToken {
133
+ player.removeTimeObserver(token)
134
+ timeObserverToken = nil
135
+ }
136
+ playerController.player = nil
137
+ playerController.dismiss(animated: false, completion: nil)
138
+ }
139
+
140
+ @objc private func togglePlay(sender: UIButton) {
141
+ if player.timeControlStatus == .playing {
142
+ player.pause()
143
+ } else {
144
+ if CMTimeCompare(trimmer.progress, trimmer.selectedRange.end) != -1 {
145
+ trimmer.progress = trimmer.selectedRange.start
146
+ player.seek(to: trimmer.progress, toleranceBefore: .zero, toleranceAfter: .zero)
147
+ }
148
+
149
+ player.play()
150
+ }
149
151
 
150
- saveBtn = UIButton(type: .system)
151
- saveBtn.setTitle(saveButtonText, for: .normal)
152
- saveBtn.titleLabel?.font = .systemFont(ofSize: 18)
153
- saveBtn.setTitleColor(.systemBlue, for: .normal)
154
- saveBtn.addTarget(self, action: #selector(onSaveBtnClicked), for: .touchUpInside)
152
+ setPlayBtnIcon()
153
+ }
154
+
155
+ @objc private func onSaveBtnClicked() {
156
+ saveBtnClicked?(trimmer.selectedRange)
157
+ }
158
+
159
+ @objc private func onCancelBtnClicked() {
160
+ cancelBtnClicked?()
161
+ }
162
+
163
+ // MARK: - Setup Methods
164
+ private func setupView() {
165
+ view.backgroundColor = .black
166
+ }
167
+
168
+ private func setupButtons() {
169
+ cancelBtn = UIButton.createButton(title: cancelBtnText, font: .systemFont(ofSize: 18), titleColor: .white, target: self, action: #selector(onCancelBtnClicked))
170
+ playBtn = UIButton.createButton(image: playIcon, tintColor: .systemBlue, target: self, action: #selector(togglePlay(sender:)))
171
+ saveBtn = UIButton.createButton(title: saveButtonText, font: .systemFont(ofSize: 18), titleColor: .systemBlue, target: self, action: #selector(onSaveBtnClicked))
155
172
 
156
173
  btnStackView = UIStackView(arrangedSubviews: [cancelBtn, playBtn, saveBtn])
157
174
  btnStackView.axis = .horizontal
@@ -163,24 +180,14 @@ class VideoTrimmerViewController: UIViewController {
163
180
  NSLayoutConstraint.activate([
164
181
  btnStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
165
182
  btnStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
166
- btnStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
183
+ btnStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
167
184
  ])
168
-
169
- // time labels
170
- leadingTrimLabel = UILabel()
171
- leadingTrimLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
172
- leadingTrimLabel.textAlignment = .left
173
- leadingTrimLabel.textColor = .white
174
-
175
- currentTimeLabel = UILabel()
176
- currentTimeLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
177
- currentTimeLabel.textAlignment = .center
178
- currentTimeLabel.textColor = .white
179
-
180
- trailingTrimLabel = UILabel()
181
- trailingTrimLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
182
- trailingTrimLabel.textAlignment = .right
183
- trailingTrimLabel.textColor = .white
185
+ }
186
+
187
+ private func setupTimeLabels() {
188
+ leadingTrimLabel = UILabel.createLabel(textAlignment: .left, textColor: .white)
189
+ currentTimeLabel = UILabel.createLabel(textAlignment: .center, textColor: .white)
190
+ trailingTrimLabel = UILabel.createLabel(textAlignment: .right, textColor: .white)
184
191
 
185
192
  timingStackView = UIStackView(arrangedSubviews: [leadingTrimLabel, currentTimeLabel, trailingTrimLabel])
186
193
  timingStackView.axis = .horizontal
@@ -192,50 +199,57 @@ class VideoTrimmerViewController: UIViewController {
192
199
  NSLayoutConstraint.activate([
193
200
  timingStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
194
201
  timingStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
195
- timingStackView.bottomAnchor.constraint(equalTo: btnStackView.topAnchor, constant: -8),
202
+ timingStackView.bottomAnchor.constraint(equalTo: btnStackView.topAnchor, constant: -8)
196
203
  ])
197
-
198
- // THIS IS WHERE WE SETUP THE VIDEOTRIMMER:
204
+ }
205
+
206
+ private func setupVideoTrimmer() {
199
207
  trimmer = VideoTrimmer()
200
- trimmer.asset = asset // this should happen before trimmer.selectedRange below otherwise its didSet will override
208
+ trimmer.asset = asset
201
209
  trimmer.minimumDuration = CMTime(seconds: 1, preferredTimescale: 600)
202
210
 
203
- if maximumDuration != nil {
204
- trimmer.maximumDuration = CMTime(seconds: max(1, Double(maximumDuration!)), preferredTimescale: 600) // minimum 1 second
205
-
206
- // guard check to make sure max duration can only <= asset.duration
207
- if CMTimeCompare(trimmer.maximumDuration, asset.duration) == 1 {
211
+ if let maxDuration = maximumDuration {
212
+ trimmer.maximumDuration = CMTime(seconds: max(1, Double(maxDuration)), preferredTimescale: 600)
213
+ if trimmer.maximumDuration > asset.duration {
208
214
  trimmer.maximumDuration = asset.duration
209
215
  }
210
-
211
216
  trimmer.selectedRange = CMTimeRange(start: .zero, end: trimmer.maximumDuration)
212
217
  }
213
-
214
- if minimumDuration != nil {
215
- trimmer.minimumDuration = CMTime(seconds: max(1, Double(minimumDuration!)), preferredTimescale: 600) // minimum 1 second
218
+
219
+ if let minDuration = minimumDuration {
220
+ trimmer.minimumDuration = CMTime(seconds: max(1, Double(minDuration)), preferredTimescale: 600)
216
221
  }
217
222
 
218
- trimmer.addTarget(self, action: #selector(didBeginTrimming(_:)), for: VideoTrimmer.didBeginTrimming)
219
- trimmer.addTarget(self, action: #selector(didEndTrimming(_:)), for: VideoTrimmer.didEndTrimming)
220
- trimmer.addTarget(self, action: #selector(selectedRangeDidChanged(_:)), for: VideoTrimmer.selectedRangeChanged)
221
223
  trimmer.addTarget(self, action: #selector(didBeginScrubbing(_:)), for: VideoTrimmer.didBeginScrubbing)
222
224
  trimmer.addTarget(self, action: #selector(didEndScrubbing(_:)), for: VideoTrimmer.didEndScrubbing)
223
225
  trimmer.addTarget(self, action: #selector(progressDidChanged(_:)), for: VideoTrimmer.progressChanged)
226
+
227
+ trimmer.addTarget(self, action: #selector(didBeginTrimmingFromStart(_:)), for: VideoTrimmer.didBeginTrimmingFromStart)
228
+ trimmer.addTarget(self, action: #selector(leadingGrabberChanged(_:)), for: VideoTrimmer.leadingGrabberChanged)
229
+ trimmer.addTarget(self, action: #selector(didEndTrimmingFromStart(_:)), for: VideoTrimmer.didEndTrimmingFromStart)
230
+
231
+ trimmer.addTarget(self, action: #selector(didBeginTrimmingFromEnd(_:)), for: VideoTrimmer.didBeginTrimmingFromEnd)
232
+ trimmer.addTarget(self, action: #selector(trailingGrabberChanged(_:)), for: VideoTrimmer.trailingGrabberChanged)
233
+ trimmer.addTarget(self, action: #selector(didEndTrimmingFromEnd(_:)), for: VideoTrimmer.didEndTrimmingFromEnd)
224
234
  view.addSubview(trimmer)
225
235
  trimmer.translatesAutoresizingMaskIntoConstraints = false
226
236
  NSLayoutConstraint.activate([
227
237
  trimmer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
228
238
  trimmer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
229
239
  trimmer.bottomAnchor.constraint(equalTo: timingStackView.topAnchor, constant: -16),
230
- trimmer.heightAnchor.constraint(equalToConstant: 50),
240
+ trimmer.heightAnchor.constraint(equalToConstant: 50)
231
241
  ])
232
-
233
- playerController.showsPlaybackControls = false // hide control buttons
242
+ }
243
+
244
+ private func setupPlayerController() {
245
+ playerController.showsPlaybackControls = false
234
246
  if #available(iOS 16.0, *) {
235
- playerController.allowsVideoFrameAnalysis = false // hide live text
247
+ playerController.allowsVideoFrameAnalysis = false
236
248
  }
237
249
  playerController.player = AVPlayer()
238
- try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) // this is to play audio even when device is in silent mode
250
+ player.replaceCurrentItem(with: AVPlayerItem(asset: asset))
251
+
252
+ try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
239
253
  addChild(playerController)
240
254
  view.addSubview(playerController.view)
241
255
  playerController.view.translatesAutoresizingMaskIntoConstraints = false
@@ -245,56 +259,100 @@ class VideoTrimmerViewController: UIViewController {
245
259
  playerController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
246
260
  playerController.view.bottomAnchor.constraint(equalTo: trimmer.topAnchor, constant: -16)
247
261
  ])
248
-
249
- updatePlayerAsset()
250
-
262
+ }
263
+
264
+ private func setupTimeObserver() {
251
265
  timeObserverToken = player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 30), queue: .main) { [weak self] time in
252
- guard let self = self else {return}
253
-
254
- currentTimeLabel.text = player.currentTime().displayString
266
+ guard let self = self else { return }
255
267
 
256
- // when we're not trimming, the players starting point is actual later than the trimmer,
257
- // (because the vidoe has been trimmed), so we need to account for that.
258
- // When we're trimming, we always show the full video
259
- let finalTime = self.trimmer.trimmingState == .none ? CMTimeAdd(time, self.trimmer.selectedRange.start) : time
260
- self.trimmer.progress = finalTime
268
+ if self.player.timeControlStatus != .playing {
269
+ return
270
+ }
261
271
 
262
- if self.player.timeControlStatus == .playing {
263
- self.playBtn.setImage(self.pauseIcon, for: .normal)
264
- } else {
265
- self.playBtn.setImage(self.playIcon, for: .normal)
272
+ self.trimmer.progress = time
273
+
274
+ // pause if reach end of selected range
275
+ if CMTimeCompare(self.trimmer.progress, trimmer.selectedRange.end) == 1 {
276
+ player.pause()
277
+ self.trimmer.progress = trimmer.selectedRange.end
278
+ player.seek(to: trimmer.selectedRange.end, toleranceBefore: .zero, toleranceAfter: .zero)
266
279
  }
280
+
281
+ currentTimeLabel.text = trimmer.progress.displayString
282
+
283
+ self.setPlayBtnIcon()
267
284
  }
268
-
269
- updateLabels()
270
285
  }
271
286
 
272
- override func viewWillDisappear(_ animated: Bool) {
273
- super.viewWillDisappear(animated)
274
-
275
- player.pause()
276
- if timeObserverToken != nil {
277
- player.removeTimeObserver(timeObserverToken as Any)
278
- timeObserverToken = nil
279
- }
280
- playerController.player = nil
281
- playerController.dismiss(animated: false, completion: nil)
287
+ private func setPlayBtnIcon() {
288
+ self.playBtn.setImage(self.player.timeControlStatus == .playing ? self.pauseIcon : self.playIcon, for: .normal)
282
289
  }
283
290
 
284
- @objc private func togglePlay(sender: UIButton) {
285
- if player.timeControlStatus == .playing {
286
- player.pause()
287
- } else {
288
- player.play()
291
+ // ====Smoother seek
292
+ public func seek(to time: CMTime) {
293
+ seekSmoothlyToTime(newChaseTime: time)
294
+ }
295
+
296
+ private func seekSmoothlyToTime(newChaseTime: CMTime) {
297
+ if CMTimeCompare(newChaseTime, chaseTime) != 0 {
298
+ chaseTime = newChaseTime
299
+
300
+ if !isSeekInProgress {
301
+ trySeekToChaseTime()
302
+ }
289
303
  }
290
-
291
304
  }
292
-
293
- @objc func onSaveBtnClicked() {
294
- saveBtnClicked?(trimmer.selectedRange)
305
+
306
+ private func trySeekToChaseTime() {
307
+ guard player?.status == .readyToPlay else { return }
308
+ actuallySeekToTime()
295
309
  }
296
-
297
- @objc func onCancelBtnClicked() {
298
- cancelBtnClicked?()
310
+
311
+ private func actuallySeekToTime() {
312
+ isSeekInProgress = true
313
+ let seekTimeInProgress = chaseTime
314
+
315
+ player?.seek(to: seekTimeInProgress, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
316
+ guard let `self` = self else { return }
317
+
318
+ if CMTimeCompare(seekTimeInProgress, self.chaseTime) == 0 {
319
+ self.isSeekInProgress = false
320
+ } else {
321
+ self.trySeekToChaseTime()
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ private extension UIButton {
328
+ static func createButton(title: String? = nil, image: UIImage? = nil, font: UIFont? = nil, titleColor: UIColor? = nil, tintColor: UIColor? = nil, target: Any?, action: Selector) -> UIButton {
329
+ let button = UIButton(type: .system)
330
+ if let title = title {
331
+ button.setTitle(title, for: .normal)
332
+ }
333
+ if let image = image {
334
+ button.setImage(image, for: .normal)
335
+ }
336
+ if let font = font {
337
+ button.titleLabel?.font = font
338
+ }
339
+ if let titleColor = titleColor {
340
+ button.setTitleColor(titleColor, for: .normal)
341
+ }
342
+ if let tintColor = tintColor {
343
+ button.tintColor = tintColor
344
+ }
345
+ button.addTarget(target, action: action, for: .touchUpInside)
346
+ return button
347
+ }
348
+ }
349
+
350
+ private extension UILabel {
351
+ static func createLabel(textAlignment: NSTextAlignment, textColor: UIColor) -> UILabel {
352
+ let label = UILabel()
353
+ label.font = UIFont.preferredFont(forTextStyle: .caption1)
354
+ label.textAlignment = textAlignment
355
+ label.textColor = textColor
356
+ return label
299
357
  }
300
358
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-video-trim",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "Video trimmer for your React Native app",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",