react-native-video-trim 1.0.10 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -5
- package/android/src/main/java/com/videotrim/VideoTrimModule.java +96 -12
- package/android/src/main/java/com/videotrim/interfaces/VideoTrimListener.java +1 -0
- package/android/src/main/java/com/videotrim/utils/StorageUtil.java +14 -143
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.java +2 -10
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.java +5 -32
- package/android/src/main/res/values/strings.xml +1 -1
- package/ios/VideoTrim.mm +6 -1
- package/ios/VideoTrim.swift +277 -118
- package/ios/VideoTrimmer.swift +808 -0
- package/ios/VideoTrimmerThumb.swift +119 -0
- package/ios/VideoTrimmerViewController.swift +292 -0
- package/lib/commonjs/index.js +64 -4
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/index.js +61 -4
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/index.d.ts +46 -3
- package/lib/typescript/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/react-native-video-trim.podspec +1 -0
- package/src/index.tsx +75 -5
|
@@ -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
|
+
|