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.
- package/README.md +6 -2
- package/VideoTrim.podspec +1 -1
- package/ios/AssetLoader.swift +99 -0
- package/ios/ErrorCode.swift +17 -0
- package/ios/ProgressAlertController.swift +100 -0
- package/ios/VideoTrim-Bridging-Header.h +1 -0
- package/ios/VideoTrim.h +2 -28
- package/ios/VideoTrim.mm +156 -639
- package/ios/VideoTrim.swift +938 -0
- package/ios/VideoTrimProtocol.swift +10 -0
- package/ios/VideoTrimmer.swift +872 -0
- package/ios/VideoTrimmerThumb.swift +175 -0
- package/ios/VideoTrimmerViewController.swift +578 -0
- package/package.json +1 -1
- package/ios/AssetLoader.h +0 -19
- package/ios/AssetLoader.mm +0 -87
- package/ios/ErrorCode.h +0 -9
- package/ios/ProgressAlertController.h +0 -12
- package/ios/ProgressAlertController.mm +0 -106
- package/ios/VideoTrimmer.h +0 -67
- package/ios/VideoTrimmer.mm +0 -863
- package/ios/VideoTrimmerThumb.h +0 -23
- package/ios/VideoTrimmerThumb.mm +0 -175
- package/ios/VideoTrimmerViewController.h +0 -52
- package/ios/VideoTrimmerViewController.mm +0 -533
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
//
|
|
2
|
+
// VideoTrimmerViewController.swift
|
|
3
|
+
// VideoTrim
|
|
4
|
+
//
|
|
5
|
+
// Created by Duc Trung Mai on 20/5/25.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import UIKit
|
|
9
|
+
import AVKit
|
|
10
|
+
import React
|
|
11
|
+
|
|
12
|
+
extension CMTime {
|
|
13
|
+
var displayString: String {
|
|
14
|
+
let offset = TimeInterval(seconds)
|
|
15
|
+
let numberOfNanosecondsFloat = (offset - TimeInterval(Int(offset))) * 100.0
|
|
16
|
+
let nanoseconds = Int(numberOfNanosecondsFloat)
|
|
17
|
+
|
|
18
|
+
let formatter = CMTime.dateFormatter
|
|
19
|
+
return String(format: "%@.%02d", formatter.string(from: offset) ?? "00:00", nanoseconds)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private static var dateFormatter: DateComponentsFormatter = {
|
|
23
|
+
let formatter = DateComponentsFormatter()
|
|
24
|
+
formatter.unitsStyle = .positional
|
|
25
|
+
formatter.zeroFormattingBehavior = .pad
|
|
26
|
+
formatter.allowedUnits = [.minute, .second]
|
|
27
|
+
return formatter
|
|
28
|
+
}()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@available(iOS 13.0, *)
|
|
32
|
+
class VideoTrimmerViewController: UIViewController {
|
|
33
|
+
var asset: AVAsset? {
|
|
34
|
+
didSet {
|
|
35
|
+
if let _ = asset {
|
|
36
|
+
setupVideoTrimmer()
|
|
37
|
+
setupPlayerController()
|
|
38
|
+
setupTimeObserver()
|
|
39
|
+
updateLabels()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
private var maximumDuration: Int?
|
|
44
|
+
private var minimumDuration: Int?
|
|
45
|
+
private var cancelButtonText = "Cancel"
|
|
46
|
+
private var saveButtonText = "Save"
|
|
47
|
+
var cancelBtnClicked: (() -> Void)?
|
|
48
|
+
var saveBtnClicked: ((CMTimeRange) -> Void)?
|
|
49
|
+
private var enableHapticFeedback = true
|
|
50
|
+
|
|
51
|
+
private let playerController = AVPlayerViewController()
|
|
52
|
+
private var trimmer: VideoTrimmer!
|
|
53
|
+
private var timingStackView: UIStackView!
|
|
54
|
+
private var leadingTrimLabel: UILabel!
|
|
55
|
+
private var currentTimeLabel: UILabel!
|
|
56
|
+
private var trailingTrimLabel: UILabel!
|
|
57
|
+
private var btnStackView: UIStackView!
|
|
58
|
+
private var cancelBtn: UIButton!
|
|
59
|
+
private var playBtn: UIButton!
|
|
60
|
+
private let loadingIndicator = UIActivityIndicatorView()
|
|
61
|
+
private var saveBtn: UIButton!
|
|
62
|
+
private let playIcon = UIImage(systemName: "play.fill")
|
|
63
|
+
private let pauseIcon = UIImage(systemName: "pause.fill")
|
|
64
|
+
private let audioBannerView = UIImage(systemName: "airpodsmax")
|
|
65
|
+
private var player: AVPlayer! { playerController.player }
|
|
66
|
+
private var timeObserverToken: Any?
|
|
67
|
+
private var autoplay = false
|
|
68
|
+
private var jumpToPositionOnLoad: Double = 0;
|
|
69
|
+
private var headerText: String?
|
|
70
|
+
private var headerTextSize = 16
|
|
71
|
+
private var headerTextColor: Double?
|
|
72
|
+
private var headerView: UIView?
|
|
73
|
+
|
|
74
|
+
var isSeekInProgress: Bool = false // Marker
|
|
75
|
+
private var chaseTime = CMTime.zero
|
|
76
|
+
private var preferredFrameRate: Float = 23.98
|
|
77
|
+
|
|
78
|
+
public func onAssetFailToLoad() {
|
|
79
|
+
loadingIndicator.stopAnimating()
|
|
80
|
+
btnStackView.removeArrangedSubview(loadingIndicator)
|
|
81
|
+
loadingIndicator.removeFromSuperview()
|
|
82
|
+
|
|
83
|
+
let imageViewContainer = UIView()
|
|
84
|
+
let imageView = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill"))
|
|
85
|
+
imageView.tintColor = .systemYellow
|
|
86
|
+
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
87
|
+
|
|
88
|
+
imageViewContainer.addSubview(imageView)
|
|
89
|
+
NSLayoutConstraint.activate([
|
|
90
|
+
imageView.widthAnchor.constraint(equalToConstant: 36),
|
|
91
|
+
imageView.heightAnchor.constraint(equalToConstant: 36),
|
|
92
|
+
imageView.centerXAnchor.constraint(equalTo: imageViewContainer.centerXAnchor),
|
|
93
|
+
imageView.centerYAnchor.constraint(equalTo: imageViewContainer.centerYAnchor)
|
|
94
|
+
])
|
|
95
|
+
imageViewContainer.alpha = 0
|
|
96
|
+
|
|
97
|
+
btnStackView.insertArrangedSubview(imageViewContainer, at: 1)
|
|
98
|
+
|
|
99
|
+
UIView.animate(withDuration: 0.25, animations: {
|
|
100
|
+
imageViewContainer.alpha = 1
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// MARK: - Input
|
|
105
|
+
@objc private func didBeginTrimmingFromStart(_ sender: VideoTrimmer) {
|
|
106
|
+
handleBeforeProgressChange()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@objc private func leadingGrabberChanged(_ sender: VideoTrimmer) {
|
|
110
|
+
handleProgressChanged(time: trimmer.selectedRange.start)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@objc private func didEndTrimmingFromStart(_ sender: VideoTrimmer) {
|
|
114
|
+
handleTrimmingEnd(true)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@objc private func didBeginTrimmingFromEnd(_ sender: VideoTrimmer) {
|
|
118
|
+
handleBeforeProgressChange()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@objc private func trailingGrabberChanged(_ sender: VideoTrimmer) {
|
|
122
|
+
handleProgressChanged(time: trimmer.selectedRange.end)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@objc private func didEndTrimmingFromEnd(_ sender: VideoTrimmer) {
|
|
126
|
+
handleTrimmingEnd(false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@objc private func didBeginScrubbing(_ sender: VideoTrimmer) {
|
|
130
|
+
handleBeforeProgressChange()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@objc private func didEndScrubbing(_ sender: VideoTrimmer) {
|
|
134
|
+
updateLabels()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@objc private func progressDidChanged(_ sender: VideoTrimmer) {
|
|
138
|
+
handleProgressChanged(time: trimmer.progress)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// MARK: - Private
|
|
142
|
+
private func updateLabels() {
|
|
143
|
+
leadingTrimLabel.text = trimmer.selectedRange.start.displayString
|
|
144
|
+
currentTimeLabel.text = trimmer.progress.displayString
|
|
145
|
+
trailingTrimLabel.text = trimmer.selectedRange.end.displayString
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private func handleBeforeProgressChange() {
|
|
149
|
+
updateLabels()
|
|
150
|
+
player.pause()
|
|
151
|
+
setPlayBtnIcon()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func handleProgressChanged(time: CMTime) {
|
|
155
|
+
updateLabels()
|
|
156
|
+
seek(to: time)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private func handleTrimmingEnd(_ start: Bool) {
|
|
160
|
+
self.trimmer.progress = start ? trimmer.selectedRange.start : trimmer.selectedRange.end
|
|
161
|
+
updateLabels()
|
|
162
|
+
seek(to: trimmer.progress)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// MARK: - UIViewController
|
|
166
|
+
override func viewDidLoad() {
|
|
167
|
+
super.viewDidLoad()
|
|
168
|
+
|
|
169
|
+
setupView()
|
|
170
|
+
setupButtons()
|
|
171
|
+
setupTimeLabels()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override func viewWillDisappear(_ animated: Bool) {
|
|
175
|
+
super.viewWillDisappear(animated)
|
|
176
|
+
|
|
177
|
+
// if asset has been initialized
|
|
178
|
+
guard let _ = asset else { return }
|
|
179
|
+
player.pause()
|
|
180
|
+
|
|
181
|
+
// Clean up the observer
|
|
182
|
+
player.removeObserver(self, forKeyPath: "status")
|
|
183
|
+
|
|
184
|
+
if let token = timeObserverToken {
|
|
185
|
+
player.removeTimeObserver(token)
|
|
186
|
+
timeObserverToken = nil
|
|
187
|
+
}
|
|
188
|
+
// Remove observer
|
|
189
|
+
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem)
|
|
190
|
+
|
|
191
|
+
playerController.player = nil
|
|
192
|
+
playerController.dismiss(animated: false, completion: nil)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public func pausePlayer() {
|
|
196
|
+
player.pause()
|
|
197
|
+
setPlayBtnIcon()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@objc private func togglePlay(sender: UIButton) {
|
|
201
|
+
if player.timeControlStatus == .playing {
|
|
202
|
+
player.pause()
|
|
203
|
+
} else {
|
|
204
|
+
if CMTimeCompare(trimmer.progress, trimmer.selectedRange.end) != -1 {
|
|
205
|
+
trimmer.progress = trimmer.selectedRange.start
|
|
206
|
+
self.seek(to: trimmer.progress)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
player.play()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setPlayBtnIcon()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@objc private func onSaveBtnClicked() {
|
|
216
|
+
saveBtnClicked?(trimmer.selectedRange)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@objc private func onCancelBtnClicked() {
|
|
220
|
+
cancelBtnClicked?()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// MARK: - Setup Methods
|
|
224
|
+
private func setupView() {
|
|
225
|
+
self.overrideUserInterfaceStyle = .dark
|
|
226
|
+
view.backgroundColor = .black // need to have this otherwise during animation the background of this VC is still white in white theme
|
|
227
|
+
|
|
228
|
+
if let headerText = headerText {
|
|
229
|
+
headerView = UIView()
|
|
230
|
+
headerView!.translatesAutoresizingMaskIntoConstraints = false
|
|
231
|
+
view.addSubview(headerView!)
|
|
232
|
+
let headerTextView = UITextView()
|
|
233
|
+
headerTextView.text = headerText
|
|
234
|
+
headerTextView.textAlignment = .center
|
|
235
|
+
|
|
236
|
+
headerTextView.textColor = RCTConvert.uiColor(headerTextColor)
|
|
237
|
+
// UIColor.color(fromHexNumber: headerTextColor as NSNumber?, defaultColor: .white)
|
|
238
|
+
|
|
239
|
+
headerTextView.font = UIFont.systemFont(ofSize: CGFloat(headerTextSize)) // Set font size here
|
|
240
|
+
headerTextView.translatesAutoresizingMaskIntoConstraints = false
|
|
241
|
+
headerView!.addSubview(headerTextView)
|
|
242
|
+
|
|
243
|
+
NSLayoutConstraint.activate([
|
|
244
|
+
// HeaderView constraints
|
|
245
|
+
headerView!.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
246
|
+
headerView!.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
247
|
+
headerView!.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
248
|
+
headerView!.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
|
|
249
|
+
|
|
250
|
+
// HeaderText constraints
|
|
251
|
+
headerTextView.topAnchor.constraint(equalTo: headerView!.topAnchor),
|
|
252
|
+
headerTextView.bottomAnchor.constraint(equalTo: headerView!.bottomAnchor),
|
|
253
|
+
headerTextView.leadingAnchor.constraint(equalTo: headerView!.leadingAnchor),
|
|
254
|
+
headerTextView.trailingAnchor.constraint(equalTo: headerView!.trailingAnchor),
|
|
255
|
+
])
|
|
256
|
+
|
|
257
|
+
view.layoutIfNeeded() // layout after activate constraints, otherwise headerView height = screen height, which leads to playerViewController is missing at runtime
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private func setupButtons() {
|
|
262
|
+
cancelBtn = UIButton.createButton(title: cancelButtonText, font: .systemFont(ofSize: 18), titleColor: .white, target: self, action: #selector(onCancelBtnClicked))
|
|
263
|
+
playBtn = UIButton.createButton(image: playIcon, tintColor: .white, target: self, action: #selector(togglePlay(sender:)))
|
|
264
|
+
playBtn.alpha = 0
|
|
265
|
+
playBtn.isEnabled = false
|
|
266
|
+
|
|
267
|
+
saveBtn = UIButton.createButton(title: saveButtonText, font: .systemFont(ofSize: 18), titleColor: .systemBlue, target: self, action: #selector(onSaveBtnClicked))
|
|
268
|
+
saveBtn.alpha = 0
|
|
269
|
+
saveBtn.isEnabled = false
|
|
270
|
+
|
|
271
|
+
btnStackView = UIStackView(arrangedSubviews: [cancelBtn, loadingIndicator, saveBtn])
|
|
272
|
+
btnStackView.axis = .horizontal
|
|
273
|
+
btnStackView.alignment = .center
|
|
274
|
+
btnStackView.distribution = .fillEqually
|
|
275
|
+
btnStackView.spacing = UIStackView.spacingUseSystem
|
|
276
|
+
btnStackView.translatesAutoresizingMaskIntoConstraints = false
|
|
277
|
+
|
|
278
|
+
view.addSubview(btnStackView)
|
|
279
|
+
|
|
280
|
+
NSLayoutConstraint.activate([
|
|
281
|
+
btnStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
|
282
|
+
btnStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
|
|
283
|
+
btnStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
|
|
284
|
+
])
|
|
285
|
+
|
|
286
|
+
loadingIndicator.startAnimating()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private func setupTimeLabels() {
|
|
290
|
+
leadingTrimLabel = UILabel.createLabel(textAlignment: .left, textColor: .white)
|
|
291
|
+
leadingTrimLabel.text = "00:00.000"
|
|
292
|
+
currentTimeLabel = UILabel.createLabel(textAlignment: .center, textColor: .white)
|
|
293
|
+
currentTimeLabel.text = "00:00.000"
|
|
294
|
+
trailingTrimLabel = UILabel.createLabel(textAlignment: .right, textColor: .white)
|
|
295
|
+
trailingTrimLabel.text = "00:00.000"
|
|
296
|
+
|
|
297
|
+
timingStackView = UIStackView(arrangedSubviews: [leadingTrimLabel, currentTimeLabel, trailingTrimLabel])
|
|
298
|
+
timingStackView.axis = .horizontal
|
|
299
|
+
timingStackView.alignment = .fill
|
|
300
|
+
timingStackView.distribution = .fillEqually
|
|
301
|
+
timingStackView.spacing = UIStackView.spacingUseSystem
|
|
302
|
+
view.addSubview(timingStackView)
|
|
303
|
+
timingStackView.translatesAutoresizingMaskIntoConstraints = false
|
|
304
|
+
NSLayoutConstraint.activate([
|
|
305
|
+
timingStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
|
306
|
+
timingStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
|
|
307
|
+
timingStackView.bottomAnchor.constraint(equalTo: btnStackView.topAnchor, constant: -8)
|
|
308
|
+
])
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private func setupVideoTrimmer() {
|
|
312
|
+
trimmer = VideoTrimmer()
|
|
313
|
+
trimmer.asset = asset
|
|
314
|
+
trimmer.minimumDuration = CMTime(seconds: 1, preferredTimescale: 600)
|
|
315
|
+
trimmer.enableHapticFeedback = enableHapticFeedback
|
|
316
|
+
|
|
317
|
+
if let maxDuration = maximumDuration {
|
|
318
|
+
trimmer.maximumDuration = CMTime(seconds: max(1, Double(maxDuration)), preferredTimescale: 600)
|
|
319
|
+
if trimmer.maximumDuration > asset!.duration {
|
|
320
|
+
trimmer.maximumDuration = asset!.duration
|
|
321
|
+
}
|
|
322
|
+
trimmer.selectedRange = CMTimeRange(start: .zero, end: trimmer.maximumDuration)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if let minDuration = minimumDuration {
|
|
326
|
+
trimmer.minimumDuration = CMTime(seconds: max(1, Double(minDuration)), preferredTimescale: 600)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
trimmer.addTarget(self, action: #selector(didBeginScrubbing(_:)), for: VideoTrimmer.didBeginScrubbing)
|
|
330
|
+
trimmer.addTarget(self, action: #selector(didEndScrubbing(_:)), for: VideoTrimmer.didEndScrubbing)
|
|
331
|
+
trimmer.addTarget(self, action: #selector(progressDidChanged(_:)), for: VideoTrimmer.progressChanged)
|
|
332
|
+
|
|
333
|
+
trimmer.addTarget(self, action: #selector(didBeginTrimmingFromStart(_:)), for: VideoTrimmer.didBeginTrimmingFromStart)
|
|
334
|
+
trimmer.addTarget(self, action: #selector(leadingGrabberChanged(_:)), for: VideoTrimmer.leadingGrabberChanged)
|
|
335
|
+
trimmer.addTarget(self, action: #selector(didEndTrimmingFromStart(_:)), for: VideoTrimmer.didEndTrimmingFromStart)
|
|
336
|
+
|
|
337
|
+
trimmer.addTarget(self, action: #selector(didBeginTrimmingFromEnd(_:)), for: VideoTrimmer.didBeginTrimmingFromEnd)
|
|
338
|
+
trimmer.addTarget(self, action: #selector(trailingGrabberChanged(_:)), for: VideoTrimmer.trailingGrabberChanged)
|
|
339
|
+
trimmer.addTarget(self, action: #selector(didEndTrimmingFromEnd(_:)), for: VideoTrimmer.didEndTrimmingFromEnd)
|
|
340
|
+
trimmer.alpha = 0
|
|
341
|
+
view.addSubview(trimmer)
|
|
342
|
+
trimmer.translatesAutoresizingMaskIntoConstraints = false
|
|
343
|
+
NSLayoutConstraint.activate([
|
|
344
|
+
trimmer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
345
|
+
trimmer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
|
346
|
+
trimmer.bottomAnchor.constraint(equalTo: timingStackView.topAnchor, constant: -16),
|
|
347
|
+
trimmer.heightAnchor.constraint(equalToConstant: 50)
|
|
348
|
+
])
|
|
349
|
+
|
|
350
|
+
UIView.animate(withDuration: 0.25, animations: {
|
|
351
|
+
self.trimmer.alpha = 1
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private func setupPlayerController() {
|
|
356
|
+
playerController.showsPlaybackControls = false
|
|
357
|
+
if #available(iOS 16.0, *) {
|
|
358
|
+
playerController.allowsVideoFrameAnalysis = false
|
|
359
|
+
}
|
|
360
|
+
playerController.player = AVPlayer()
|
|
361
|
+
player.replaceCurrentItem(with: AVPlayerItem(asset: asset!))
|
|
362
|
+
|
|
363
|
+
// Add observer for player status
|
|
364
|
+
player.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)
|
|
365
|
+
|
|
366
|
+
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
|
367
|
+
addChild(playerController)
|
|
368
|
+
view.addSubview(playerController.view)
|
|
369
|
+
playerController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
370
|
+
NSLayoutConstraint.activate([
|
|
371
|
+
playerController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
372
|
+
playerController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
373
|
+
playerController.view.topAnchor.constraint(equalTo: headerView != nil ? headerView!.bottomAnchor : view.safeAreaLayoutGuide.topAnchor),
|
|
374
|
+
playerController.view.bottomAnchor.constraint(equalTo: trimmer.topAnchor, constant: -16)
|
|
375
|
+
])
|
|
376
|
+
|
|
377
|
+
// Add observer for the end of playback
|
|
378
|
+
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinishPlaying), name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
@objc private func playerDidFinishPlaying(note: NSNotification) {
|
|
382
|
+
// Directly set the play icon
|
|
383
|
+
// the reason in at this time player.timeControlStatus == .playing still returns true
|
|
384
|
+
playBtn.setImage(self.playIcon, for: .normal)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private func setupTimeObserver() {
|
|
388
|
+
timeObserverToken = player.addPeriodicTimeObserver(forInterval: CMTime(value: 1, timescale: 30), queue: .main) { [weak self] time in
|
|
389
|
+
guard let self = self else { return }
|
|
390
|
+
|
|
391
|
+
if self.player.timeControlStatus != .playing {
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
self.trimmer.progress = time
|
|
396
|
+
|
|
397
|
+
// pause if reach end of selected range
|
|
398
|
+
if CMTimeCompare(self.trimmer.progress, trimmer.selectedRange.end) == 1 {
|
|
399
|
+
player.pause()
|
|
400
|
+
self.trimmer.progress = trimmer.selectedRange.end
|
|
401
|
+
self.seek(to: trimmer.selectedRange.end)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
currentTimeLabel.text = trimmer.progress.displayString
|
|
405
|
+
|
|
406
|
+
self.setPlayBtnIcon()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private func setPlayBtnIcon() {
|
|
411
|
+
self.playBtn.setImage(self.player.timeControlStatus == .playing ? self.pauseIcon : self.playIcon, for: .normal)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ====Smoother seek
|
|
415
|
+
public func seek(to time: CMTime) {
|
|
416
|
+
seekSmoothlyToTime(newChaseTime: time)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private func seekSmoothlyToTime(newChaseTime: CMTime) {
|
|
420
|
+
if CMTimeCompare(newChaseTime, chaseTime) != 0 {
|
|
421
|
+
chaseTime = newChaseTime
|
|
422
|
+
|
|
423
|
+
if !isSeekInProgress {
|
|
424
|
+
trySeekToChaseTime()
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private func trySeekToChaseTime() {
|
|
430
|
+
guard player?.status == .readyToPlay else { return }
|
|
431
|
+
actuallySeekToTime()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private func actuallySeekToTime() {
|
|
435
|
+
isSeekInProgress = true
|
|
436
|
+
let seekTimeInProgress = chaseTime
|
|
437
|
+
|
|
438
|
+
player?.seek(to: seekTimeInProgress, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
|
|
439
|
+
guard let `self` = self else { return }
|
|
440
|
+
|
|
441
|
+
if CMTimeCompare(seekTimeInProgress, self.chaseTime) == 0 {
|
|
442
|
+
self.isSeekInProgress = false
|
|
443
|
+
} else {
|
|
444
|
+
self.trySeekToChaseTime()
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
public func configure(config: NSDictionary) {
|
|
450
|
+
if let maxDuration = config["maxDuration"] as? Int, maxDuration > 0 {
|
|
451
|
+
maximumDuration = maxDuration
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if let minDuration = config["minDuration"] as? Int, minDuration > 0 {
|
|
455
|
+
minimumDuration = minDuration
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if let cancelText = config["cancelButtonText"] as? String, !cancelText.isEmpty {
|
|
459
|
+
cancelButtonText = cancelText
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if let saveText = config["saveButtonText"] as? String, !saveText.isEmpty {
|
|
463
|
+
saveButtonText = saveText
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if let jumpPosition = config["jumpToPositionOnLoad"] as? Double, jumpPosition >= 0 {
|
|
467
|
+
jumpToPositionOnLoad = jumpPosition
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if let enableHaptic = config["enableHapticFeedback"] as? Bool {
|
|
471
|
+
enableHapticFeedback = enableHaptic
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if let autoPlay = config["autoplay"] as? Bool {
|
|
475
|
+
autoplay = autoPlay
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if let headerText = config["headerText"] as? String, !headerText.isEmpty {
|
|
479
|
+
self.headerText = headerText
|
|
480
|
+
|
|
481
|
+
if let textSize = config["headerTextSize"] as? Int, textSize > 0 {
|
|
482
|
+
headerTextSize = textSize
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if let textColor = config["headerTextColor"] as? Double {
|
|
486
|
+
headerTextColor = textColor
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
492
|
+
if keyPath == "status" {
|
|
493
|
+
if player.status == .readyToPlay {
|
|
494
|
+
loadingIndicator.stopAnimating()
|
|
495
|
+
btnStackView.removeArrangedSubview(loadingIndicator)
|
|
496
|
+
loadingIndicator.removeFromSuperview()
|
|
497
|
+
btnStackView.insertArrangedSubview(playBtn, at: 1)
|
|
498
|
+
|
|
499
|
+
UIView.animate(withDuration: 0.25, animations: {
|
|
500
|
+
self.playBtn.alpha = 1
|
|
501
|
+
self.playBtn.isEnabled = true
|
|
502
|
+
self.saveBtn.alpha = 1
|
|
503
|
+
self.saveBtn.isEnabled = true
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
if jumpToPositionOnLoad > 0 {
|
|
507
|
+
let duration = (asset?.duration.seconds ?? 0) * 1000
|
|
508
|
+
let time = jumpToPositionOnLoad > duration ? duration : jumpToPositionOnLoad
|
|
509
|
+
let cmtime = CMTime(value: CMTimeValue(time), timescale: 1000)
|
|
510
|
+
|
|
511
|
+
self.seek(to: cmtime)
|
|
512
|
+
self.trimmer.progress = cmtime
|
|
513
|
+
self.currentTimeLabel.text = self.trimmer.progress.displayString
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if autoplay {
|
|
517
|
+
togglePlay(sender: playBtn)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private extension UIButton {
|
|
525
|
+
static func createButton(title: String? = nil, image: UIImage? = nil, font: UIFont? = nil, titleColor: UIColor? = nil, tintColor: UIColor? = nil, target: Any?, action: Selector) -> UIButton {
|
|
526
|
+
let button = UIButton(type: .system)
|
|
527
|
+
if let title = title {
|
|
528
|
+
button.setTitle(title, for: .normal)
|
|
529
|
+
}
|
|
530
|
+
if let image = image {
|
|
531
|
+
button.setImage(image, for: .normal)
|
|
532
|
+
}
|
|
533
|
+
if let font = font {
|
|
534
|
+
button.titleLabel?.font = font
|
|
535
|
+
}
|
|
536
|
+
if let titleColor = titleColor {
|
|
537
|
+
button.setTitleColor(titleColor, for: .normal)
|
|
538
|
+
}
|
|
539
|
+
if let tintColor = tintColor {
|
|
540
|
+
button.tintColor = tintColor
|
|
541
|
+
}
|
|
542
|
+
button.addTarget(target, action: action, for: .touchUpInside)
|
|
543
|
+
return button
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private extension UILabel {
|
|
548
|
+
static func createLabel(textAlignment: NSTextAlignment, textColor: UIColor) -> UILabel {
|
|
549
|
+
let label = UILabel()
|
|
550
|
+
label.font = UIFont.preferredFont(forTextStyle: .caption1)
|
|
551
|
+
label.textAlignment = textAlignment
|
|
552
|
+
label.textColor = textColor
|
|
553
|
+
return label
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
//extension UIColor {
|
|
558
|
+
// static func color(fromHexNumber hex: NSNumber?, defaultColor: UIColor = .black) -> UIColor {
|
|
559
|
+
// guard let hexValue = hex?.int32Value else {
|
|
560
|
+
// return defaultColor
|
|
561
|
+
// }
|
|
562
|
+
//
|
|
563
|
+
// // Extract RGB components from the hex value
|
|
564
|
+
// let red = CGFloat((hexValue >> 16) & 0xFF) / 255.0 // Extract red (bits 16-23)
|
|
565
|
+
// let green = CGFloat((hexValue >> 8) & 0xFF) / 255.0 // Extract green (bits 8-15)
|
|
566
|
+
// let blue = CGFloat(hexValue & 0xFF) / 255.0 // Extract blue (bits 0-7)
|
|
567
|
+
//
|
|
568
|
+
// // Check if alpha is included (if hex is 0xAARRGGBB)
|
|
569
|
+
// let alpha: CGFloat
|
|
570
|
+
// if hexValue > 0xFFFFFF { // If the value is larger than 0xFFFFFF, it includes alpha
|
|
571
|
+
// alpha = CGFloat((hexValue >> 24) & 0xFF) / 255.0 // Extract alpha (bits 24-31)
|
|
572
|
+
// } else {
|
|
573
|
+
// alpha = 1.0 // Default to opaque
|
|
574
|
+
// }
|
|
575
|
+
//
|
|
576
|
+
// return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
|
577
|
+
// }
|
|
578
|
+
//}
|
package/package.json
CHANGED
package/ios/AssetLoader.h
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
#import <Foundation/Foundation.h>
|
|
2
|
-
#import <AVFoundation/AVFoundation.h>
|
|
3
|
-
|
|
4
|
-
@class AssetLoader;
|
|
5
|
-
|
|
6
|
-
@protocol AssetLoaderDelegate <NSObject>
|
|
7
|
-
@optional
|
|
8
|
-
- (void)assetLoaderDidSucceed:(AssetLoader *)assetLoader;
|
|
9
|
-
- (void)assetLoader:(AssetLoader *)assetLoader didFailWithError:(NSError *)error forKey:(NSString *)key;
|
|
10
|
-
@end
|
|
11
|
-
|
|
12
|
-
@interface AssetLoader : NSObject
|
|
13
|
-
|
|
14
|
-
@property (nonatomic, weak) id<AssetLoaderDelegate> delegate;
|
|
15
|
-
@property (nonatomic, strong, readonly) AVURLAsset *asset;
|
|
16
|
-
|
|
17
|
-
- (void)loadAssetWithURL:(NSURL *)url isVideoType:(BOOL)isVideoType;
|
|
18
|
-
|
|
19
|
-
@end
|
package/ios/AssetLoader.mm
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
#import "AssetLoader.h"
|
|
2
|
-
|
|
3
|
-
@interface AssetLoader ()
|
|
4
|
-
@property (nonatomic, strong) AVURLAsset *asset;
|
|
5
|
-
@end
|
|
6
|
-
|
|
7
|
-
@implementation AssetLoader
|
|
8
|
-
|
|
9
|
-
- (void)loadAssetWithURL:(NSURL *)url isVideoType:(BOOL)isVideoType {
|
|
10
|
-
NSDictionary *options = @{ AVURLAssetPreferPreciseDurationAndTimingKey: @YES };
|
|
11
|
-
self.asset = [AVURLAsset URLAssetWithURL:url options:options];
|
|
12
|
-
NSArray *keys = @[ @"duration", @"tracks" ];
|
|
13
|
-
[self.asset loadValuesAsynchronouslyForKeys:keys completionHandler:^{
|
|
14
|
-
[self assetLoaded:isVideoType];
|
|
15
|
-
}];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
- (void)assetLoaded:(BOOL)isVideoType {
|
|
19
|
-
NSArray *keys = @[ @"duration", @"tracks" ];
|
|
20
|
-
for (NSString *key in keys) {
|
|
21
|
-
NSError *error = nil;
|
|
22
|
-
AVKeyValueStatus status = [self.asset statusOfValueForKey:key error:&error];
|
|
23
|
-
if (status == AVKeyValueStatusFailed) {
|
|
24
|
-
if ([self.delegate respondsToSelector:@selector(assetLoader:didFailWithError:forKey:)]) {
|
|
25
|
-
[self.delegate assetLoader:self didFailWithError:error forKey:key];
|
|
26
|
-
}
|
|
27
|
-
return;
|
|
28
|
-
} else if (status == AVKeyValueStatusCancelled) {
|
|
29
|
-
NSError *cancelError = [NSError errorWithDomain:@"AssetLoader" code:-1 userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"%@ loading was cancelled", key] }];
|
|
30
|
-
if ([self.delegate respondsToSelector:@selector(assetLoader:didFailWithError:forKey:)]) {
|
|
31
|
-
[self.delegate assetLoader:self didFailWithError:cancelError forKey:key];
|
|
32
|
-
}
|
|
33
|
-
return;
|
|
34
|
-
} else if (status != AVKeyValueStatusLoaded) {
|
|
35
|
-
NSError *unknownError = [NSError errorWithDomain:@"AssetLoader" code:-1 userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"%@ is in an unknown state", key] }];
|
|
36
|
-
if ([self.delegate respondsToSelector:@selector(assetLoader:didFailWithError:forKey:)]) {
|
|
37
|
-
[self.delegate assetLoader:self didFailWithError:unknownError forKey:key];
|
|
38
|
-
}
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (isVideoType) {
|
|
43
|
-
[self processAssetTracks];
|
|
44
|
-
} else {
|
|
45
|
-
if ([self.delegate respondsToSelector:@selector(assetLoaderDidSucceed:)]) {
|
|
46
|
-
[self.delegate assetLoaderDidSucceed:self];
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
- (void)processAssetTracks {
|
|
52
|
-
NSArray *videoTracks = [self.asset tracksWithMediaType:AVMediaTypeVideo];
|
|
53
|
-
AVAssetTrack *videoTrack = videoTracks.firstObject;
|
|
54
|
-
if (!videoTrack) {
|
|
55
|
-
NSError *error = [NSError errorWithDomain:@"AssetLoader" code:-1 userInfo:@{ NSLocalizedDescriptionKey: @"No video tracks found" }];
|
|
56
|
-
if ([self.delegate respondsToSelector:@selector(assetLoader:didFailWithError:forKey:)]) {
|
|
57
|
-
[self.delegate assetLoader:self didFailWithError:error forKey:@"tracks"];
|
|
58
|
-
}
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
NSArray *trackKeys = @[ @"naturalSize", @"preferredTransform" ];
|
|
62
|
-
[videoTrack loadValuesAsynchronouslyForKeys:trackKeys completionHandler:^{
|
|
63
|
-
[self trackPropertiesLoaded:videoTrack];
|
|
64
|
-
}];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
- (void)trackPropertiesLoaded:(AVAssetTrack *)track {
|
|
68
|
-
NSError *error = nil;
|
|
69
|
-
AVKeyValueStatus naturalSizeStatus = [track statusOfValueForKey:@"naturalSize" error:&error];
|
|
70
|
-
AVKeyValueStatus preferredTransformStatus = [track statusOfValueForKey:@"preferredTransform" error:&error];
|
|
71
|
-
if (naturalSizeStatus == AVKeyValueStatusLoaded && preferredTransformStatus == AVKeyValueStatusLoaded) {
|
|
72
|
-
CGSize naturalSize = track.naturalSize;
|
|
73
|
-
CGAffineTransform preferredTransform = track.preferredTransform;
|
|
74
|
-
NSLog(@"Natural size: %@", NSStringFromCGSize(naturalSize));
|
|
75
|
-
NSLog(@"Preferred transform: %@", NSStringFromCGAffineTransform(preferredTransform));
|
|
76
|
-
if ([self.delegate respondsToSelector:@selector(assetLoaderDidSucceed:)]) {
|
|
77
|
-
[self.delegate assetLoaderDidSucceed:self];
|
|
78
|
-
}
|
|
79
|
-
} else {
|
|
80
|
-
if ([self.delegate respondsToSelector:@selector(assetLoader:didFailWithError:forKey:)]) {
|
|
81
|
-
NSString *failedKey = (naturalSizeStatus != AVKeyValueStatusLoaded) ? @"naturalSize" : @"preferredTransform";
|
|
82
|
-
[self.delegate assetLoader:self didFailWithError:error forKey:failedKey];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
@end
|