react-native-video-trim 5.0.5 → 5.1.1

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,872 @@
1
+ //
2
+ // VideoTrimmer.swift
3
+ // VideoTrim
4
+ //
5
+ // Created by Duc Trung Mai on 20/5/25.
6
+ //
7
+
8
+ import UIKit
9
+ import AVFoundation
10
+
11
+ // Controls that allows trimming a range and scrubbing a progress indicator
12
+ @available(iOS 13.0, *)
13
+ @IBDesignable class VideoTrimmer: UIControl {
14
+
15
+ // events for changing selectedRange ("trimming")
16
+ static let didBeginTrimmingFromStart = UIControl.Event(rawValue: 1 << 19)
17
+ static let leadingGrabberChanged = UIControl.Event(rawValue: 1 << 20)
18
+ static let didEndTrimmingFromStart = UIControl.Event(rawValue: 1 << 21)
19
+
20
+ static let didBeginTrimmingFromEnd = UIControl.Event(rawValue: 1 << 22)
21
+ static let trailingGrabberChanged = UIControl.Event(rawValue: 1 << 23)
22
+ static let didEndTrimmingFromEnd = UIControl.Event(rawValue: 1 << 24)
23
+
24
+ // events for scrubbing the progress indicator ("scrubbing")
25
+ static let didBeginScrubbing = UIControl.Event(rawValue: 0b00001000 << 24)
26
+ static let progressChanged = UIControl.Event(rawValue: 0b00010000 << 24)
27
+ static let didEndScrubbing = UIControl.Event(rawValue: 0b00100000 << 24)
28
+
29
+ private struct Thumbnail {
30
+ let uuid = UUID()
31
+ let imageView: UIImageView
32
+ let time: CMTime
33
+ }
34
+
35
+ // currently there're warnings in the console saying that initial width of thumbView is 0
36
+ // TODO: migrate all to AutoLayout
37
+ private let thumbView: VideoTrimmerThumb = {
38
+ let view = VideoTrimmerThumb()
39
+ view.accessibilityIdentifier = "thumbView"
40
+ return view
41
+ }()
42
+
43
+ private let wrapperView: UIView = {
44
+ let view = UIView()
45
+ view.accessibilityIdentifier = "wrapperView"
46
+ view.translatesAutoresizingMaskIntoConstraints = false
47
+ return view
48
+ }()
49
+
50
+ private let shadowView: UIView = {
51
+ let view = UIView()
52
+ view.accessibilityIdentifier = "shadowView"
53
+ view.translatesAutoresizingMaskIntoConstraints = false
54
+
55
+ return view
56
+ }()
57
+
58
+ private let thumbnailClipView: UIView = {
59
+ let view = UIView()
60
+ view.accessibilityIdentifier = "thumbnailClipView"
61
+ view.translatesAutoresizingMaskIntoConstraints = false
62
+
63
+ return view
64
+ }()
65
+
66
+ private let thumbnailWrapperView: UIView = {
67
+ let view = UIView()
68
+ view.accessibilityIdentifier = "thumbnailWrapperView"
69
+
70
+ return view
71
+ }()
72
+
73
+ private let thumbnailTrackView: UIView = {
74
+ let view = UIView()
75
+ view.accessibilityIdentifier = "thumbnailTrackView"
76
+
77
+ return view
78
+ }()
79
+
80
+ private let thumbnailLeadingCoverView: UIView = {
81
+ let view = UIView()
82
+ view.accessibilityIdentifier = "thumbnailLeadingCoverView"
83
+
84
+ return view
85
+ }()
86
+
87
+ private let thumbnailTrailingCoverView: UIView = {
88
+ let view = UIView()
89
+ view.accessibilityIdentifier = "thumbnailTrailingCoverView"
90
+
91
+ return view
92
+ }()
93
+
94
+ private let leadingThumbRest: UIView = {
95
+ let view = UIView()
96
+ view.accessibilityIdentifier = "leadingThumbRest"
97
+ view.translatesAutoresizingMaskIntoConstraints = false
98
+ return view
99
+ }()
100
+
101
+ private let trailingThumbRest: UIView = {
102
+ let view = UIView()
103
+ view.accessibilityIdentifier = "trailingThumbRest"
104
+ view.translatesAutoresizingMaskIntoConstraints = false
105
+ return view
106
+ }()
107
+
108
+ private let progressIndicator: UIView = {
109
+ let view = UIView()
110
+ view.accessibilityIdentifier = "progressIndicator"
111
+ return view
112
+ }()
113
+
114
+ private let progressIndicatorControl: UIControl = {
115
+ let view = UIControl()
116
+ view.accessibilityIdentifier = "progressIndicatorControl"
117
+
118
+ return view
119
+ }()
120
+
121
+ // defines how much the control is insetted from its sides:
122
+ // this is set to 16, so that you can have the control fullscreen (and have it
123
+ // edge-to-edge when zooming in)
124
+ @IBInspectable var horizontalInset: CGFloat = 16 {
125
+ didSet {
126
+ guard horizontalInset != oldValue else {return}
127
+ setNeedsLayout()
128
+ }
129
+ }
130
+
131
+ // the asset to use
132
+ var asset: AVAsset? {
133
+ didSet {
134
+ if let asset = asset {
135
+ let duration = asset.duration
136
+ range = CMTimeRange(start: .zero, duration: duration)
137
+ selectedRange = range
138
+ lastKnownViewSizeForThumbnailGeneration = .zero
139
+ setNeedsLayout()
140
+ }
141
+ }
142
+ }
143
+
144
+ // the video composition to use
145
+ var videoComposition: AVVideoComposition? {
146
+ didSet {
147
+ lastKnownViewSizeForThumbnailGeneration = .zero
148
+ setNeedsLayout()
149
+ }
150
+ }
151
+
152
+ // a clip cannot be trimmed shorter than this duration
153
+ var minimumDuration: CMTime = CMTime(seconds: 1, preferredTimescale: 600)
154
+ var maximumDuration: CMTime = .positiveInfinity
155
+ var enableHapticFeedback = true
156
+
157
+ // the available range of the asset.
158
+ // Will be set to the full duration of the asset when assigning a new asset
159
+ var range: CMTimeRange = .invalid {
160
+ didSet {
161
+ setNeedsLayout()
162
+ }
163
+ }
164
+
165
+ // the range that is selected, will be set to the full duration
166
+ // when changing asset.
167
+ var selectedRange: CMTimeRange = .invalid {
168
+ didSet {
169
+ setNeedsLayout()
170
+ }
171
+ }
172
+
173
+ // defines what to do with the progress indicator
174
+ enum ProgressIndicatorMode {
175
+ case hiddenOnlyWhenTrimming // the progress indicator gets hidden when the user starts trimming
176
+ case alwaysShown // the progress indicator is always shown, even when the user is trimming
177
+ case alwaysHidden // the progress indicator is never shown
178
+ }
179
+ var progressIndicatorMode = ProgressIndicatorMode.hiddenOnlyWhenTrimming {
180
+ didSet {
181
+ updateProgressIndicator()
182
+ }
183
+ }
184
+
185
+ // defines where the progress indicator is shown.
186
+ var progress: CMTime = .zero {
187
+ didSet {
188
+ setNeedsLayout()
189
+ }
190
+ }
191
+
192
+ // defines if the user is trimming or not, and if so, which edge
193
+ enum TrimmingState {
194
+ case none // user isn't trimming
195
+ case leading // user is trimming the leading part of the asset
196
+ case trailing // user is trimming the trailing part of the asset
197
+ }
198
+
199
+ private(set) var trimmingState = TrimmingState.none {
200
+ didSet {
201
+ UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.25, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: {
202
+ self.shadowView.layer.shadowOpacity = (self.trimmingState != .none ? 0.5 : 0.25)
203
+ self.shadowView.layer.shadowRadius = (self.trimmingState != .none ? 4 : 2)
204
+ })
205
+ }
206
+ }
207
+
208
+ // yes if the user is zoomed in
209
+ private(set) var isZoomedIn = false
210
+ private(set) var zoomedInRange: CMTimeRange = .zero
211
+
212
+
213
+ // yes if the user is scrubbing the progress indicator
214
+ private(set) var isScrubbing = false
215
+
216
+ // background color for the track
217
+ var trackBackgroundColor = UIColor.black {
218
+ didSet {
219
+ thumbnailWrapperView.backgroundColor = trackBackgroundColor
220
+ }
221
+ }
222
+
223
+ // background color for the place where the thumbs rest on when the selectedRange == range
224
+ var thumbRestColor = UIColor.black {
225
+ didSet {
226
+ leadingThumbRest.backgroundColor = thumbRestColor
227
+ trailingThumbRest.backgroundColor = thumbRestColor
228
+ }
229
+ }
230
+
231
+ // the range that's currently visible: could be less than "range" when zoomed in
232
+ var visibleRange: CMTimeRange {
233
+ return isZoomedIn == true ? zoomedInRange : range
234
+ }
235
+
236
+ // the time that's currently selected by the user when trimming
237
+ var selectedTime: CMTime {
238
+ switch trimmingState {
239
+ case .none: return .zero
240
+ case .leading: return selectedRange.start
241
+ case .trailing: return selectedRange.end
242
+ }
243
+ }
244
+
245
+ // gesture recognizers used. Can be used, for instance, to
246
+ // require a tableview panGestureRecognizer to fail
247
+ private (set) var leadingGestureRecognizer: UILongPressGestureRecognizer!
248
+ private (set) var trailingGestureRecognizer: UILongPressGestureRecognizer!
249
+ private (set) var progressGestureRecognizer: UILongPressGestureRecognizer!
250
+ private (set) var thumbnailInteractionGestureRecognizer: UILongPressGestureRecognizer!
251
+
252
+ // private stuff
253
+ private var grabberOffset = CGFloat(0)
254
+ private var zoomWaitTimer: Timer?
255
+
256
+ private var lastKnownViewSizeForThumbnailGeneration: CGSize = .zero
257
+ private var thumbnailSize: CGSize = .zero
258
+ private var lastKnownThumbnailRange: CMTimeRange = .zero
259
+ private var thumbnails = Array<Thumbnail>()
260
+ private var generator: AVAssetImageGenerator?
261
+
262
+ private var impactFeedbackGenerator: UIImpactFeedbackGenerator?
263
+ private var didClampWhilePanning = false
264
+
265
+
266
+ // MARK: - Private
267
+ private func setup() {
268
+ addSubview(thumbnailClipView)
269
+ thumbnailClipView.addSubview(thumbnailWrapperView)
270
+ thumbnailWrapperView.addSubview(leadingThumbRest)
271
+ thumbnailWrapperView.addSubview(trailingThumbRest)
272
+ thumbnailWrapperView.addSubview(thumbnailTrackView)
273
+ thumbnailWrapperView.addSubview(thumbnailLeadingCoverView)
274
+ thumbnailWrapperView.addSubview(thumbnailTrailingCoverView)
275
+
276
+ progressIndicator.backgroundColor = .white
277
+ progressIndicator.layer.shadowColor = UIColor.black.cgColor
278
+ progressIndicator.layer.shadowOffset = .zero
279
+ progressIndicator.layer.shadowRadius = 2
280
+ progressIndicator.layer.shadowOpacity = 0.25
281
+ progressIndicator.layer.cornerRadius = 2
282
+ progressIndicator.layer.cornerCurve = .continuous
283
+
284
+ addSubview(shadowView)
285
+ wrapperView.clipsToBounds = true
286
+ shadowView.addSubview(wrapperView)
287
+ wrapperView.addSubview(thumbView)
288
+ wrapperView.addSubview(progressIndicator)
289
+ wrapperView.addSubview(progressIndicatorControl)
290
+
291
+ thumbnailClipView.clipsToBounds = true
292
+ thumbnailTrackView.clipsToBounds = true
293
+ thumbnailLeadingCoverView.backgroundColor = UIColor(white: 0, alpha: 0.75)
294
+ thumbnailTrailingCoverView.backgroundColor = UIColor(white: 0, alpha: 0.75)
295
+
296
+ leadingThumbRest.backgroundColor = thumbRestColor
297
+ trailingThumbRest.backgroundColor = thumbRestColor
298
+
299
+ thumbnailWrapperView.backgroundColor = trackBackgroundColor
300
+ thumbnailWrapperView.layer.cornerRadius = 6
301
+ thumbnailWrapperView.layer.cornerCurve = .continuous
302
+
303
+ leadingThumbRest.layer.cornerRadius = 6
304
+ leadingThumbRest.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMinXMinYCorner]
305
+ leadingThumbRest.layer.cornerCurve = .continuous
306
+
307
+ trailingThumbRest.layer.cornerRadius = 6
308
+ trailingThumbRest.layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMaxXMinYCorner]
309
+ trailingThumbRest.layer.cornerCurve = .continuous
310
+
311
+ shadowView.layer.shadowColor = UIColor.black.cgColor
312
+ shadowView.layer.shadowOffset = .zero
313
+ shadowView.layer.shadowRadius = 2
314
+ shadowView.layer.shadowOpacity = 0.25
315
+
316
+ setupConstraints()
317
+ setupGestures()
318
+ }
319
+
320
+ // thumbView.topAnchor.constraint(equalTo: thumbnailWrapperView.topAnchor),
321
+ // thumbView.bottomAnchor.constraint(equalTo: thumbnailWrapperView.bottomAnchor),
322
+ // thumbView.leadingAnchor.constraint(equalTo: thumbnailWrapperView.leadingAnchor),
323
+ // thumbView.trailingAnchor.constraint(equalTo: thumbnailWrapperView.trailingAnchor),
324
+ private func setupConstraints() {
325
+ NSLayoutConstraint.activate([
326
+ thumbnailClipView.topAnchor.constraint(equalTo: self.topAnchor),
327
+ thumbnailClipView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
328
+ thumbnailClipView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
329
+ thumbnailClipView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
330
+
331
+ shadowView.topAnchor.constraint(equalTo: self.topAnchor),
332
+ shadowView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
333
+ shadowView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
334
+ shadowView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
335
+
336
+ wrapperView.topAnchor.constraint(equalTo: shadowView.topAnchor),
337
+ wrapperView.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor),
338
+ wrapperView.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor),
339
+ wrapperView.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor),
340
+
341
+ leadingThumbRest.topAnchor.constraint(equalTo: thumbnailWrapperView.topAnchor),
342
+ leadingThumbRest.bottomAnchor.constraint(equalTo: thumbnailWrapperView.bottomAnchor),
343
+ leadingThumbRest.leadingAnchor.constraint(equalTo: thumbnailWrapperView.leadingAnchor),
344
+ leadingThumbRest.widthAnchor.constraint(equalToConstant: thumbView.chevronWidth),
345
+
346
+ trailingThumbRest.topAnchor.constraint(equalTo: thumbnailWrapperView.topAnchor),
347
+ trailingThumbRest.bottomAnchor.constraint(equalTo: thumbnailWrapperView.bottomAnchor),
348
+ trailingThumbRest.trailingAnchor.constraint(equalTo: thumbnailWrapperView.trailingAnchor),
349
+ trailingThumbRest.widthAnchor.constraint(equalToConstant: thumbView.chevronWidth),
350
+ ])
351
+ }
352
+
353
+ private func setupGestures() {
354
+ leadingGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(leadingGrabberPanned(_:)))
355
+ leadingGestureRecognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
356
+ leadingGestureRecognizer.minimumPressDuration = 0
357
+ thumbView.leadingGrabber.addGestureRecognizer(leadingGestureRecognizer)
358
+
359
+ trailingGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(trailingGrabberPanned(_:)))
360
+ trailingGestureRecognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
361
+ trailingGestureRecognizer.minimumPressDuration = 0
362
+ thumbView.trailingGrabber.addGestureRecognizer(trailingGestureRecognizer)
363
+
364
+ progressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(progressGrabberPanned(_:)))
365
+ progressGestureRecognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
366
+ progressGestureRecognizer.minimumPressDuration = 0
367
+ progressGestureRecognizer.require(toFail: leadingGestureRecognizer)
368
+ progressGestureRecognizer.require(toFail: trailingGestureRecognizer)
369
+ progressIndicatorControl.addGestureRecognizer(progressGestureRecognizer)
370
+
371
+ thumbnailInteractionGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(thumbnailPanned(_:)))
372
+ thumbnailInteractionGestureRecognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
373
+ thumbnailInteractionGestureRecognizer.minimumPressDuration = 0
374
+ thumbnailInteractionGestureRecognizer.require(toFail: leadingGestureRecognizer)
375
+ thumbnailInteractionGestureRecognizer.require(toFail: trailingGestureRecognizer)
376
+ thumbView.addGestureRecognizer(thumbnailInteractionGestureRecognizer)
377
+ }
378
+
379
+ private func regenerateThumbnailsIfNeeded() {
380
+ let size = bounds.size
381
+ guard size.width > 0 && size.height > 0 else {return}
382
+ guard lastKnownViewSizeForThumbnailGeneration != size || CMTimeRangeEqual(lastKnownThumbnailRange, visibleRange) == false else {return}
383
+ guard let asset = asset else {return}
384
+ guard let track = asset.tracks(withMediaType: .video).first else {return}
385
+
386
+ lastKnownViewSizeForThumbnailGeneration = size
387
+ lastKnownThumbnailRange = visibleRange
388
+
389
+ let naturalSize = track.naturalSize
390
+ let transform = track.preferredTransform
391
+ let fixedSize = naturalSize.applyingVideoTransform(transform)
392
+
393
+ let generator = AVAssetImageGenerator(asset: asset)
394
+ generator.apertureMode = .cleanAperture
395
+ generator.videoComposition = videoComposition
396
+ self.generator = generator
397
+
398
+ let height = size.height - thumbView.edgeHeight * 2
399
+ thumbnailSize = CGSize(width: height / fixedSize.height * fixedSize.width, height: height)
400
+ let numberOfThumbnails = Int(ceil(size.width / thumbnailSize.width))
401
+
402
+ var newThumbnails = Array<Thumbnail>()
403
+ let thumbnailDuration = visibleRange.duration.seconds / Double(numberOfThumbnails)
404
+ var times = Array<NSValue>()
405
+ // we add some extra thumbnails as padding
406
+ for index in -3..<numberOfThumbnails + 6 {
407
+ let time = CMTimeAdd(visibleRange.start, CMTime(seconds: thumbnailDuration * Double(index), preferredTimescale: asset.duration.timescale * 2))
408
+ guard CMTimeCompare(time, .zero) != -1 else {continue}
409
+ times.append(NSValue(time: time))
410
+
411
+ let newThumbnail = Thumbnail(imageView: UIImageView(), time: time)
412
+ self.thumbnailTrackView.addSubview(newThumbnail.imageView)
413
+ newThumbnails.append(newThumbnail)
414
+ }
415
+
416
+ generator.appliesPreferredTrackTransform = true
417
+ generator.maximumSize = CGSize(width: thumbnailSize.width * UIScreen.main.scale, height: thumbnailSize.height * UIScreen.main.scale)
418
+
419
+ let oldThumbnails = thumbnails
420
+ thumbnails.append(contentsOf: newThumbnails)
421
+
422
+ UIView.animate(withDuration: 0.25, delay: 0.25, options: [.beginFromCurrentState], animations: {
423
+ oldThumbnails.forEach {$0.imageView.alpha = 0}
424
+ }, completion: { _ in
425
+ oldThumbnails.forEach {$0.imageView.removeFromSuperview()}
426
+ let uuidsToRemove = Set(oldThumbnails.map({$0.uuid}))
427
+ self.thumbnails.removeAll(where: {uuidsToRemove.contains($0.uuid)})
428
+ })
429
+
430
+ var seenIndex = 0
431
+ generator.requestedTimeToleranceBefore = .zero
432
+ generator.requestedTimeToleranceAfter = .zero
433
+ generator.generateCGImagesAsynchronously(forTimes: times) { requestedTime, cgImage, actualTime, result, error in
434
+ DispatchQueue.main.async {
435
+ seenIndex += 1
436
+
437
+ guard let cgImage = cgImage else {return}
438
+ let image = UIImage(cgImage: cgImage)
439
+
440
+ let imageView = newThumbnails[seenIndex - 1].imageView
441
+ UIView.transition(with: imageView, duration: 0.25, options: [.transitionCrossDissolve], animations: {
442
+ imageView.image = image
443
+ })
444
+ }
445
+ }
446
+ }
447
+
448
+ private func timeForLocation(_ x: CGFloat) -> CMTime {
449
+ let size = bounds.size
450
+ let inset = thumbView.chevronWidth + horizontalInset
451
+ let offset = x - inset
452
+
453
+ let availableWidth = size.width - inset * 2
454
+ let visibleDurationInSeconds = CGFloat(visibleRange.duration.seconds)
455
+ let ratio = visibleDurationInSeconds != 0 ? availableWidth / visibleDurationInSeconds : 0
456
+
457
+ let timeDifference = CMTime(seconds: Double(offset / ratio), preferredTimescale: 600)
458
+ return CMTimeAdd(visibleRange.start, timeDifference)
459
+ }
460
+
461
+ private func locationForTime(_ time: CMTime) -> CGFloat {
462
+ let size = bounds.size
463
+ let inset = thumbView.chevronWidth + horizontalInset
464
+ let availableWidth = size.width - inset * 2
465
+
466
+ let offset = CMTimeSubtract(time, visibleRange.start)
467
+
468
+ let visibleDurationInSeconds = CGFloat(visibleRange.duration.seconds)
469
+ let ratio = visibleDurationInSeconds != 0 ? availableWidth / visibleDurationInSeconds : 0
470
+
471
+ let location = CGFloat(offset.seconds) * ratio
472
+ return SnapToDevicePixels(location) + inset
473
+ }
474
+
475
+ private func startZoomWaitTimer() {
476
+ stopZoomWaitTimer()
477
+ guard isZoomedIn == false else {return}
478
+ zoomWaitTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
479
+ guard let self = self else {return}
480
+ self.stopZoomWaitTimer()
481
+ self.zoomIfNeeded()
482
+ })
483
+ }
484
+
485
+ private func stopZoomWaitTimer() {
486
+ zoomWaitTimer?.invalidate()
487
+ zoomWaitTimer = nil
488
+ }
489
+
490
+ private func stopZoomIfNeeded() {
491
+ stopZoomWaitTimer()
492
+ isZoomedIn = false
493
+ animateChanges()
494
+ }
495
+
496
+ private func zoomIfNeeded() {
497
+ guard isZoomedIn == false else {return}
498
+
499
+ let size = bounds.size
500
+ let inset = thumbView.chevronWidth + horizontalInset
501
+ let availableWidth = size.width - inset * 2
502
+ let newDuration = CGFloat(range.duration.seconds > 4 ? 2.0 : range.duration.seconds * 0.5)
503
+
504
+ let durationTime = CMTime(seconds: Double(newDuration), preferredTimescale: 600)
505
+
506
+ if trimmingState == .leading {
507
+ let position = locationForTime(selectedRange.start) - inset
508
+ let start = position / availableWidth * newDuration
509
+ zoomedInRange = CMTimeRange(start: CMTimeSubtract(selectedRange.start, CMTime(seconds: Double(start), preferredTimescale: 600)), duration: durationTime)
510
+ } else {
511
+ let position = locationForTime(selectedRange.end) - inset
512
+
513
+ let durationToStart = position / availableWidth * newDuration
514
+ let newStart = CMTimeSubtract(selectedRange.end, CMTime(seconds: Double(durationToStart), preferredTimescale: 600))
515
+ zoomedInRange = CMTimeRange(start: newStart, duration: durationTime)
516
+ }
517
+
518
+ isZoomedIn = true
519
+ animateChanges()
520
+
521
+ if enableHapticFeedback {
522
+ UISelectionFeedbackGenerator().selectionChanged()
523
+ }
524
+ }
525
+
526
+ private func animateChanges() {
527
+ setNeedsLayout()
528
+ thumbView.setNeedsLayout()
529
+ UIView.animate(withDuration: 0.5, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: {
530
+ self.layoutIfNeeded()
531
+ self.thumbView.layoutIfNeeded()
532
+ })
533
+ }
534
+
535
+ private func startPanning() {
536
+ didClampWhilePanning = false
537
+
538
+ if enableHapticFeedback {
539
+ UISelectionFeedbackGenerator().selectionChanged()
540
+ impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
541
+ impactFeedbackGenerator?.prepare()
542
+ }
543
+
544
+ UIView.animate(withDuration: 0.25, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: {
545
+ self.updateProgressIndicator()
546
+ })
547
+ }
548
+
549
+ private func stopPanning() {
550
+ trimmingState = .none
551
+ stopZoomIfNeeded()
552
+ impactFeedbackGenerator = nil
553
+
554
+ UIView.animate(withDuration: 0.25, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: {
555
+ self.updateProgressIndicator()
556
+ })
557
+ }
558
+
559
+ private func updateProgressIndicator() {
560
+ switch progressIndicatorMode {
561
+ case .alwaysHidden:
562
+ progressIndicator.alpha = 0
563
+ progressIndicatorControl.isUserInteractionEnabled = false
564
+
565
+ case .alwaysShown:
566
+ progressIndicator.alpha = 1
567
+ progressIndicatorControl.isUserInteractionEnabled = true
568
+ setNeedsLayout()
569
+
570
+ case .hiddenOnlyWhenTrimming:
571
+ progressIndicator.alpha = (trimmingState == .none ? 1 : 0)
572
+ progressIndicatorControl.isUserInteractionEnabled = (trimmingState == .none)
573
+ if trimmingState == .none {
574
+ setNeedsLayout()
575
+ if UIView.inheritedAnimationDuration > 0 {
576
+ UIView.performWithoutAnimation {
577
+ layoutIfNeeded()
578
+ }
579
+ }
580
+ }
581
+ }
582
+ progressIndicatorControl.alpha = progressIndicator.alpha
583
+ }
584
+
585
+
586
+ // MARK: - Input
587
+ @objc private func thumbnailPanned(_ sender: UILongPressGestureRecognizer) {
588
+ progressGrabberPanned(sender)
589
+ }
590
+
591
+
592
+ @objc private func progressGrabberPanned(_ sender: UILongPressGestureRecognizer) {
593
+
594
+ func handleChanged() {
595
+ let location = sender.location(in: self)
596
+ var time = timeForLocation(location.x + grabberOffset)
597
+
598
+ var didClamp = false
599
+ if CMTimeCompare(time, selectedRange.start) == -1 {
600
+ time = selectedRange.start
601
+ didClamp = true
602
+ }
603
+ if CMTimeCompare(time, selectedRange.end) == 1 {
604
+ time = selectedRange.end
605
+ didClamp = true
606
+ }
607
+
608
+ if didClamp == true && didClamp != didClampWhilePanning {
609
+ impactFeedbackGenerator?.impactOccurred()
610
+ }
611
+ didClampWhilePanning = didClamp
612
+
613
+ progress = time
614
+ setNeedsLayout()
615
+ sendActions(for: Self.progressChanged)
616
+ }
617
+ switch sender.state {
618
+ case .began:
619
+ if enableHapticFeedback {
620
+ UISelectionFeedbackGenerator().selectionChanged()
621
+ impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
622
+ impactFeedbackGenerator?.prepare()
623
+ }
624
+
625
+ didClampWhilePanning = false
626
+
627
+ isScrubbing = true
628
+ sendActions(for: Self.didBeginScrubbing)
629
+ handleChanged()
630
+
631
+ case .changed:
632
+ handleChanged()
633
+
634
+ case .ended, .cancelled:
635
+ impactFeedbackGenerator = nil
636
+
637
+ isScrubbing = false
638
+ sendActions(for: Self.didEndScrubbing)
639
+
640
+ case .possible, .failed:
641
+ break
642
+
643
+ @unknown default:
644
+ break
645
+ }
646
+ }
647
+
648
+
649
+ @objc private func leadingGrabberPanned(_ sender: UILongPressGestureRecognizer) {
650
+ switch sender.state {
651
+ case .began:
652
+ trimmingState = .leading
653
+ grabberOffset = thumbView.chevronWidth - sender.location(in: thumbView.leadingGrabber).x
654
+
655
+ startPanning()
656
+ sendActions(for: Self.didBeginTrimmingFromStart)
657
+
658
+ case .changed:
659
+ let location = sender.location(in: self)
660
+ let current = timeForLocation(location.x + grabberOffset)
661
+ let min = CMTimeSubtract(selectedRange.end, minimumDuration)
662
+
663
+ var didClamp = false
664
+ var newRange = CMTimeRange(start: current, end: selectedRange.end)
665
+
666
+ if CMTimeCompare(current, min) != -1 {
667
+ newRange = CMTimeRange(start: min, end: selectedRange.end)
668
+ didClamp = true
669
+ } else if CMTimeCompare(newRange.duration, maximumDuration) != -1 {
670
+ let time = CMTimeSubtract(selectedRange.end, maximumDuration)
671
+ newRange = CMTimeRange(start: time, end: selectedRange.end)
672
+ didClamp = true
673
+ } else if CMTimeCompare(newRange.start, range.start) != 1 {
674
+ // prevent startTime to be smaller than video startTime
675
+ newRange = CMTimeRange(start: range.start, end: selectedRange.end)
676
+ didClamp = true
677
+ } else if CMTimeCompare(newRange.duration, minimumDuration) != 1 {
678
+ newRange = CMTimeRange(start: min, end: selectedRange.end)
679
+ didClamp = true
680
+ }
681
+
682
+ if didClamp == true && didClamp != didClampWhilePanning {
683
+ impactFeedbackGenerator?.impactOccurred()
684
+ }
685
+
686
+ didClampWhilePanning = didClamp
687
+ selectedRange = newRange
688
+ sendActions(for: Self.leadingGrabberChanged)
689
+ setNeedsLayout()
690
+
691
+ startZoomWaitTimer()
692
+
693
+ case .ended:
694
+ stopPanning()
695
+ sendActions(for: Self.didEndTrimmingFromStart)
696
+
697
+ case .cancelled:
698
+ stopPanning()
699
+
700
+ case .possible, .failed:
701
+ break
702
+
703
+ @unknown default:
704
+ break
705
+ }
706
+ }
707
+
708
+ @objc private func trailingGrabberPanned(_ sender: UILongPressGestureRecognizer) {
709
+ switch sender.state {
710
+ case .began:
711
+ trimmingState = .trailing
712
+ grabberOffset = sender.location(in: thumbView.trailingGrabber).x
713
+
714
+ startPanning()
715
+ sendActions(for: Self.didBeginTrimmingFromEnd)
716
+
717
+ case .changed:
718
+ let location = sender.location(in: self)
719
+
720
+ let current = timeForLocation(location.x - grabberOffset)
721
+ let min = CMTimeAdd(selectedRange.start, minimumDuration)
722
+
723
+ var didClamp = false
724
+ var newRange = CMTimeRange(start: selectedRange.start, end: timeForLocation(location.x - grabberOffset))
725
+
726
+ if CMTimeCompare(current, min) == -1 {
727
+ newRange = CMTimeRange(start: selectedRange.start, end: min)
728
+ didClamp = true
729
+ } else if CMTimeCompare(newRange.duration, maximumDuration) != -1 {
730
+ let time = CMTimeAdd(selectedRange.start, maximumDuration)
731
+ newRange = CMTimeRange(start: selectedRange.start, end: time)
732
+ didClamp = true
733
+ } else if CMTimeCompare(newRange.end, range.end) != -1 {
734
+ // prevent endTime to be greater than video endTime
735
+ newRange = CMTimeRange(start: selectedRange.start, end: range.end)
736
+ didClamp = true
737
+ } else if CMTimeCompare(newRange.duration, minimumDuration) != 1 {
738
+ newRange = CMTimeRange(start: selectedRange.start, end: min)
739
+ didClamp = true
740
+ }
741
+
742
+ if didClamp == true && didClamp != didClampWhilePanning {
743
+ impactFeedbackGenerator?.impactOccurred()
744
+ }
745
+
746
+ didClampWhilePanning = didClamp
747
+ selectedRange = newRange
748
+ sendActions(for: Self.trailingGrabberChanged)
749
+ setNeedsLayout()
750
+
751
+ startZoomWaitTimer()
752
+
753
+ case .ended:
754
+ stopPanning()
755
+ sendActions(for: Self.didEndTrimmingFromEnd)
756
+
757
+ case .cancelled:
758
+ stopPanning()
759
+
760
+ case .possible, .failed:
761
+ break
762
+
763
+ @unknown default:
764
+ break
765
+ }
766
+ }
767
+
768
+ // MARK: - UIView
769
+
770
+ override var intrinsicContentSize: CGSize {
771
+ return CGSize(width: UIView.noIntrinsicMetric, height: 50)
772
+ }
773
+
774
+ override func layoutSubviews() {
775
+ super.layoutSubviews()
776
+
777
+ let size = bounds.size
778
+ let inset = thumbView.chevronWidth
779
+ var left = locationForTime(selectedRange.start) - inset
780
+ var right = locationForTime(selectedRange.end) + inset
781
+
782
+ if right > bounds.width {
783
+ right = bounds.width + inset * 2
784
+ }
785
+
786
+ if left < 0 {
787
+ left = -inset
788
+ }
789
+
790
+ let rect = CGRect(origin: .zero, size: size)
791
+ thumbView.frame = CGRect(x: left, y: 0, width: max(right - left, inset * 2), height: size.height)
792
+
793
+ let isZoomedToEnd = (trimmingState == .leading && isZoomedIn == true)
794
+
795
+ let thumbnailOffset = (isZoomedIn == true ? horizontalInset + inset + 6 : 0)
796
+ let coverOffset = thumbnailOffset - horizontalInset
797
+ let coverStartOffset = (isZoomedIn == false ? inset : 0)
798
+
799
+ let thumbnailRect = rect.insetBy(dx: horizontalInset - thumbnailOffset, dy: thumbView.edgeHeight)
800
+ thumbnailWrapperView.frame = thumbnailRect
801
+ thumbnailTrackView.frame = CGRect(origin: .zero, size: CGSize(width: thumbnailRect.width - (isZoomedToEnd == false ? inset : 0), height: thumbnailRect.height))
802
+ thumbnailLeadingCoverView.frame = CGRect(x: coverStartOffset, y: 0, width: left + inset * 0.5 + coverOffset - coverStartOffset, height: thumbnailRect.height)
803
+ thumbnailTrailingCoverView.frame = CGRect(x: right - inset * 0.5 + coverOffset, y: 0, width: thumbnailRect.width - coverStartOffset - (right - inset * 0.5 + coverOffset), height: thumbnailRect.height)
804
+
805
+ if progressIndicator.alpha > 0 {
806
+ let progressWidth = CGFloat(4)
807
+ let progressIndicatorOffset = locationForTime(progress)
808
+ let progressLeft = min(max(thumbView.frame.minX + inset, progressIndicatorOffset - progressWidth * 0.5), thumbView.frame.maxX - inset - progressWidth)
809
+ progressIndicator.frame = CGRect(x: progressLeft, y: thumbnailRect.minY, width: progressWidth, height: thumbnailRect.height)
810
+
811
+ let progressControlWidth = CGFloat(24)
812
+
813
+ var progressControlLeft = max(thumbView.frame.minX + inset, progressLeft)
814
+ var progressControlRight = progressLeft + progressControlWidth
815
+ if progressControlRight > thumbView.frame.maxX - inset {
816
+ progressControlRight = thumbView.frame.maxX - inset
817
+ progressControlLeft = max(thumbView.frame.minX + inset, progressControlRight - progressControlWidth)
818
+ }
819
+ progressIndicatorControl.frame = CGRect(x: progressControlLeft, y: thumbnailRect.minY, width: progressControlRight - progressControlLeft, height: thumbnailRect.height)
820
+ }
821
+
822
+ regenerateThumbnailsIfNeeded()
823
+
824
+ for thumbnail in thumbnails {
825
+ let position = locationForTime(thumbnail.time) - horizontalInset + thumbnailOffset
826
+ let frame = CGRect(x: position, y: 0, width: thumbnailSize.width, height: thumbnailSize.height)
827
+ if thumbnail.imageView.bounds.width == 0 {
828
+ UIView.performWithoutAnimation {
829
+ thumbnail.imageView.frame = frame
830
+ }
831
+ } else {
832
+ thumbnail.imageView.frame = frame
833
+ }
834
+ }
835
+ }
836
+
837
+ override init(frame: CGRect) {
838
+ super.init(frame: frame)
839
+ setup()
840
+ }
841
+
842
+ required init?(coder: NSCoder) {
843
+ super.init(coder: coder)
844
+ setup()
845
+ }
846
+ }
847
+
848
+ // MARK: -
849
+
850
+ fileprivate func SnapToDevicePixels(_ value: CGFloat, scale: CGFloat? = nil) -> CGFloat {
851
+ let actualScale = scale ?? UIScreen.main.scale
852
+ return round(value * actualScale) / actualScale
853
+ }
854
+
855
+ fileprivate func SnapToDevicePixels(_ rect: CGRect, scale: CGFloat? = nil) -> CGRect {
856
+ return CGRect(x: SnapToDevicePixels(rect.origin.x, scale: scale),
857
+ y: SnapToDevicePixels(rect.origin.y, scale: scale),
858
+ width: SnapToDevicePixels(rect.maxX - rect.minX, scale: scale),
859
+ height: SnapToDevicePixels(rect.maxY - rect.minY, scale: scale))
860
+ }
861
+
862
+ fileprivate extension CGRect {
863
+ func snappedToDevicePixels(scale: CGFloat? = nil) -> CGRect {
864
+ return SnapToDevicePixels(self, scale: scale)
865
+ }
866
+ }
867
+
868
+ fileprivate extension CGSize {
869
+ func applyingVideoTransform(_ transform: CGAffineTransform) -> CGSize {
870
+ return CGRect(origin: .zero, size: self).applying(transform).size
871
+ }
872
+ }