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