react-native-video-trim 7.0.1 → 7.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 +67 -9
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +1 -1
- package/android/src/main/java/com/videotrim/widgets/AudioWaveformView.kt +92 -0
- package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +10 -24
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +606 -32
- package/ios/AudioWaveformView.swift +75 -0
- package/ios/CropOverlayView.swift +7 -11
- package/ios/VideoTrim.mm +30 -0
- package/ios/VideoTrim.swift +7 -4
- package/ios/VideoTrimmer.swift +322 -12
- package/ios/VideoTrimmerViewController.swift +114 -44
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +16 -4
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +15 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +15 -0
- package/src/index.tsx +35 -4
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Custom UIView that draws an audio waveform as a row of vertical rounded-rect bars.
|
|
4
|
+
///
|
|
5
|
+
/// Each bar's height is driven by a normalized amplitude value in [0, 1].
|
|
6
|
+
/// The view recalculates bar count from its own width and maps the amplitudes
|
|
7
|
+
/// array proportionally, so it works correctly regardless of whether the
|
|
8
|
+
/// amplitudes array has more or fewer entries than the visible bar count.
|
|
9
|
+
///
|
|
10
|
+
/// The `backgroundColor` provides the waveform track color; bars are drawn
|
|
11
|
+
/// on top with `barColor`.
|
|
12
|
+
class AudioWaveformView: UIView {
|
|
13
|
+
var amplitudes: [CGFloat] = [] {
|
|
14
|
+
didSet { setNeedsDisplay() }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
var barColor: UIColor = .white {
|
|
18
|
+
didSet { setNeedsDisplay() }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
var barWidth: CGFloat = 3 {
|
|
22
|
+
didSet { setNeedsDisplay() }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var barGap: CGFloat = 2 {
|
|
26
|
+
didSet { setNeedsDisplay() }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var barCornerRadius: CGFloat = 1.5 {
|
|
30
|
+
didSet { setNeedsDisplay() }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
override init(frame: CGRect) {
|
|
34
|
+
super.init(frame: frame)
|
|
35
|
+
isOpaque = false
|
|
36
|
+
contentMode = .redraw
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
required init?(coder: NSCoder) {
|
|
40
|
+
super.init(coder: coder)
|
|
41
|
+
isOpaque = false
|
|
42
|
+
contentMode = .redraw
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override func draw(_ rect: CGRect) {
|
|
46
|
+
guard !amplitudes.isEmpty else { return }
|
|
47
|
+
guard let ctx = UIGraphicsGetCurrentContext() else { return }
|
|
48
|
+
|
|
49
|
+
let totalHeight = rect.height
|
|
50
|
+
let step = barWidth + barGap
|
|
51
|
+
let barCount = Int(floor(rect.width / step))
|
|
52
|
+
guard barCount > 0 else { return }
|
|
53
|
+
|
|
54
|
+
// Keep bars from touching the container edges
|
|
55
|
+
let verticalPadding = barWidth * 1.5
|
|
56
|
+
let drawableHeight = totalHeight - verticalPadding * 2
|
|
57
|
+
guard drawableHeight > 0 else { return }
|
|
58
|
+
let minBarHeight = barWidth
|
|
59
|
+
|
|
60
|
+
ctx.setFillColor(barColor.cgColor)
|
|
61
|
+
|
|
62
|
+
for i in 0..<barCount {
|
|
63
|
+
let ampIndex = i * amplitudes.count / barCount
|
|
64
|
+
let amp = amplitudes[min(ampIndex, amplitudes.count - 1)]
|
|
65
|
+
let barHeight = max(minBarHeight, amp * drawableHeight)
|
|
66
|
+
let x = CGFloat(i) * step
|
|
67
|
+
let y = verticalPadding + (drawableHeight - barHeight) / 2.0
|
|
68
|
+
let barRect = CGRect(x: x, y: y, width: barWidth, height: barHeight)
|
|
69
|
+
let path = UIBezierPath(roundedRect: barRect, cornerRadius: barCornerRadius)
|
|
70
|
+
ctx.addPath(path.cgPath)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ctx.fillPath()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -26,6 +26,10 @@ class CropOverlayView: UIView {
|
|
|
26
26
|
var onCropBegan: (() -> Void)?
|
|
27
27
|
var onCropEnded: (() -> Void)?
|
|
28
28
|
|
|
29
|
+
var isLightTheme = false {
|
|
30
|
+
didSet { setNeedsDisplay() }
|
|
31
|
+
}
|
|
32
|
+
|
|
29
33
|
private let minCropSize: CGFloat = 60
|
|
30
34
|
private let borderWidth: CGFloat = 1.0
|
|
31
35
|
private let cornerLength: CGFloat = 20
|
|
@@ -73,16 +77,8 @@ class CropOverlayView: UIView {
|
|
|
73
77
|
guard !cropRect.isEmpty, let ctx = UIGraphicsGetCurrentContext() else { return }
|
|
74
78
|
let cr = cropRect
|
|
75
79
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
fullPath.append(UIBezierPath(rect: cr))
|
|
79
|
-
fullPath.usesEvenOddFillRule = true
|
|
80
|
-
ctx.addPath(fullPath.cgPath)
|
|
81
|
-
ctx.setFillColor(UIColor.black.withAlphaComponent(0.55).cgColor)
|
|
82
|
-
ctx.fillPath(using: .evenOdd)
|
|
83
|
-
ctx.restoreGState()
|
|
84
|
-
|
|
85
|
-
ctx.setStrokeColor(UIColor.white.cgColor)
|
|
80
|
+
let strokeColor = (isLightTheme ? UIColor.black : UIColor.white).cgColor
|
|
81
|
+
ctx.setStrokeColor(strokeColor)
|
|
86
82
|
ctx.setLineWidth(borderWidth)
|
|
87
83
|
ctx.stroke(cr)
|
|
88
84
|
|
|
@@ -99,7 +95,7 @@ class CropOverlayView: UIView {
|
|
|
99
95
|
}
|
|
100
96
|
ctx.strokePath()
|
|
101
97
|
|
|
102
|
-
ctx.setStrokeColor(
|
|
98
|
+
ctx.setStrokeColor(strokeColor)
|
|
103
99
|
ctx.setLineWidth(cornerWidth)
|
|
104
100
|
ctx.setLineCap(.round)
|
|
105
101
|
ctx.setLineJoin(.round)
|
package/ios/VideoTrim.mm
CHANGED
|
@@ -155,11 +155,41 @@ RCT_EXPORT_MODULE()
|
|
|
155
155
|
dict[@"handleIconColor"] = @(handleIconColorOpt.value());
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
auto waveformColorOpt = config.waveformColor();
|
|
159
|
+
if (waveformColorOpt.has_value()) {
|
|
160
|
+
dict[@"waveformColor"] = @(waveformColorOpt.value());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
auto waveformBackgroundColorOpt = config.waveformBackgroundColor();
|
|
164
|
+
if (waveformBackgroundColorOpt.has_value()) {
|
|
165
|
+
dict[@"waveformBackgroundColor"] = @(waveformBackgroundColorOpt.value());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
auto waveformBarWidthOpt = config.waveformBarWidth();
|
|
169
|
+
if (waveformBarWidthOpt.has_value()) {
|
|
170
|
+
dict[@"waveformBarWidth"] = @(waveformBarWidthOpt.value());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
auto waveformBarGapOpt = config.waveformBarGap();
|
|
174
|
+
if (waveformBarGapOpt.has_value()) {
|
|
175
|
+
dict[@"waveformBarGap"] = @(waveformBarGapOpt.value());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
auto waveformBarCornerRadiusOpt = config.waveformBarCornerRadius();
|
|
179
|
+
if (waveformBarCornerRadiusOpt.has_value()) {
|
|
180
|
+
dict[@"waveformBarCornerRadius"] = @(waveformBarCornerRadiusOpt.value());
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
auto zoomOnWaitingDurationOpt = config.zoomOnWaitingDuration();
|
|
159
184
|
if (zoomOnWaitingDurationOpt.has_value()) {
|
|
160
185
|
dict[@"zoomOnWaitingDuration"] = @(zoomOnWaitingDurationOpt.value());
|
|
161
186
|
}
|
|
162
187
|
|
|
188
|
+
NSString *theme = config.theme();
|
|
189
|
+
if (theme != nil) {
|
|
190
|
+
dict[@"theme"] = theme;
|
|
191
|
+
}
|
|
192
|
+
|
|
163
193
|
[self->videoTrim showEditor:filePath withConfig:dict];
|
|
164
194
|
}
|
|
165
195
|
|
package/ios/VideoTrim.swift
CHANGED
|
@@ -13,6 +13,9 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
13
13
|
private var vc: VideoTrimmerViewController?
|
|
14
14
|
private var outputFile: URL?
|
|
15
15
|
private var editorConfig: NSDictionary?
|
|
16
|
+
private var isLightTheme: Bool {
|
|
17
|
+
return (editorConfig?["theme"] as? String) == "light"
|
|
18
|
+
}
|
|
16
19
|
|
|
17
20
|
// MARK: base options
|
|
18
21
|
private var saveToPhoto: Bool {
|
|
@@ -289,7 +292,7 @@ public class VideoTrim: RCTEventEmitter, AssetLoaderDelegate, UIDocumentPickerDe
|
|
|
289
292
|
progressAlert.onDismiss = {
|
|
290
293
|
if self.enableCancelTrimmingDialog {
|
|
291
294
|
let dialogMessage = UIAlertController(title: self.cancelTrimmingDialogTitle, message: self.cancelTrimmingDialogMessage, preferredStyle: .alert)
|
|
292
|
-
dialogMessage.overrideUserInterfaceStyle = .dark
|
|
295
|
+
dialogMessage.overrideUserInterfaceStyle = self.isLightTheme ? .light : .dark
|
|
293
296
|
|
|
294
297
|
// Create OK button with action handler
|
|
295
298
|
let ok = UIAlertAction(title: self.cancelTrimmingDialogConfirmText, style: .destructive, handler: { (action) -> Void in
|
|
@@ -879,7 +882,7 @@ extension VideoTrim {
|
|
|
879
882
|
|
|
880
883
|
// Create Alert
|
|
881
884
|
let dialogMessage = UIAlertController(title: self.cancelDialogTitle, message: self.cancelDialogMessage, preferredStyle: .alert)
|
|
882
|
-
dialogMessage.overrideUserInterfaceStyle = .dark
|
|
885
|
+
dialogMessage.overrideUserInterfaceStyle = self.isLightTheme ? .light : .dark
|
|
883
886
|
|
|
884
887
|
// Create OK button with action handler
|
|
885
888
|
let ok = UIAlertAction(title: self.cancelDialogConfirmText, style: .destructive, handler: { (action) -> Void in
|
|
@@ -911,7 +914,7 @@ extension VideoTrim {
|
|
|
911
914
|
|
|
912
915
|
// Create Alert
|
|
913
916
|
let dialogMessage = UIAlertController(title: self.saveDialogTitle, message: self.saveDialogMessage, preferredStyle: .alert)
|
|
914
|
-
dialogMessage.overrideUserInterfaceStyle = .dark
|
|
917
|
+
dialogMessage.overrideUserInterfaceStyle = self.isLightTheme ? .light : .dark
|
|
915
918
|
|
|
916
919
|
// Create OK button with action handler
|
|
917
920
|
let ok = UIAlertAction(title: self.saveDialogConfirmText, style: .default, handler: { (action) -> Void in
|
|
@@ -1134,7 +1137,7 @@ extension VideoTrim {
|
|
|
1134
1137
|
|
|
1135
1138
|
if alertOnFailToLoad {
|
|
1136
1139
|
let dialogMessage = UIAlertController(title: alertOnFailTitle, message: alertOnFailMessage, preferredStyle: .alert)
|
|
1137
|
-
dialogMessage.overrideUserInterfaceStyle = .dark
|
|
1140
|
+
dialogMessage.overrideUserInterfaceStyle = isLightTheme ? .light : .dark
|
|
1138
1141
|
|
|
1139
1142
|
// Create Cancel button with action handlder
|
|
1140
1143
|
let ok = UIAlertAction(title: alertOnFailCloseText, style: .default)
|
package/ios/VideoTrimmer.swift
CHANGED
|
@@ -133,14 +133,27 @@ import AVFoundation
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// the asset to use
|
|
137
136
|
var asset: AVAsset? {
|
|
138
137
|
didSet {
|
|
138
|
+
// Clean up *all* async resources unconditionally — even when
|
|
139
|
+
// asset is set to nil (e.g. editor dismissal triggers this via
|
|
140
|
+
// VideoTrimmerViewController.viewWillDisappear).
|
|
141
|
+
generator?.cancelAllCGImageGeneration()
|
|
142
|
+
currentAssetReader?.cancelReading()
|
|
143
|
+
currentAssetReader = nil
|
|
144
|
+
audioDownloadTask?.cancel()
|
|
145
|
+
audioDownloadTask = nil
|
|
146
|
+
cleanupLocalAudioFile()
|
|
147
|
+
localAudioAsset = nil
|
|
148
|
+
|
|
139
149
|
if let asset = asset {
|
|
150
|
+
applyThemeColors()
|
|
140
151
|
let duration = asset.duration
|
|
141
152
|
range = CMTimeRange(start: .zero, duration: duration)
|
|
142
153
|
selectedRange = range
|
|
143
154
|
lastKnownViewSizeForThumbnailGeneration = .zero
|
|
155
|
+
lastKnownWaveformSize = .zero
|
|
156
|
+
lastKnownWaveformRange = .zero
|
|
144
157
|
setNeedsLayout()
|
|
145
158
|
}
|
|
146
159
|
}
|
|
@@ -159,6 +172,35 @@ import AVFoundation
|
|
|
159
172
|
var maximumDuration: CMTime = .positiveInfinity
|
|
160
173
|
var enableHapticFeedback = true
|
|
161
174
|
var zoomOnWaitingDuration: Double = 5.0 // Default: 5 seconds
|
|
175
|
+
var isLightTheme = false
|
|
176
|
+
/// Explicitly set from JS config (`type != "video"`).
|
|
177
|
+
/// Using a dedicated flag instead of inspecting AVAsset tracks avoids
|
|
178
|
+
/// false negatives — some audio files (e.g. M4A with album art) report
|
|
179
|
+
/// a video track, which caused the original `.video` track guard to
|
|
180
|
+
/// skip waveform generation entirely.
|
|
181
|
+
var isAudioOnly = false {
|
|
182
|
+
didSet {
|
|
183
|
+
lastKnownWaveformSize = .zero
|
|
184
|
+
setNeedsLayout()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// MARK: - Waveform customisation (forwarded to AudioWaveformView)
|
|
189
|
+
var waveformBarColor: UIColor = .white {
|
|
190
|
+
didSet { waveformView.barColor = waveformBarColor }
|
|
191
|
+
}
|
|
192
|
+
var waveformBgColor: UIColor = UIColor(red: 0.204, green: 0.471, blue: 0.965, alpha: 1) {
|
|
193
|
+
didSet { waveformView.backgroundColor = waveformBgColor }
|
|
194
|
+
}
|
|
195
|
+
var waveformBarWidth: CGFloat = 3 {
|
|
196
|
+
didSet { waveformView.barWidth = waveformBarWidth }
|
|
197
|
+
}
|
|
198
|
+
var waveformBarGap: CGFloat = 2 {
|
|
199
|
+
didSet { waveformView.barGap = waveformBarGap }
|
|
200
|
+
}
|
|
201
|
+
var waveformBarCornerRadius: CGFloat = 1.5 {
|
|
202
|
+
didSet { waveformView.barCornerRadius = waveformBarCornerRadius }
|
|
203
|
+
}
|
|
162
204
|
|
|
163
205
|
// the available range of the asset.
|
|
164
206
|
// Will be set to the full duration of the asset when assigning a new asset
|
|
@@ -219,14 +261,12 @@ import AVFoundation
|
|
|
219
261
|
// yes if the user is scrubbing the progress indicator
|
|
220
262
|
private(set) var isScrubbing = false
|
|
221
263
|
|
|
222
|
-
// background color for the track
|
|
223
264
|
var trackBackgroundColor = UIColor.black {
|
|
224
265
|
didSet {
|
|
225
266
|
thumbnailWrapperView.backgroundColor = trackBackgroundColor
|
|
226
267
|
}
|
|
227
268
|
}
|
|
228
269
|
|
|
229
|
-
// background color for the place where the thumbs rest on when the selectedRange == range
|
|
230
270
|
var thumbRestColor = UIColor.black {
|
|
231
271
|
didSet {
|
|
232
272
|
leadingThumbRest.backgroundColor = thumbRestColor
|
|
@@ -271,22 +311,66 @@ import AVFoundation
|
|
|
271
311
|
private var thumbnails = Array<Thumbnail>()
|
|
272
312
|
private var generator: AVAssetImageGenerator?
|
|
273
313
|
|
|
314
|
+
// MARK: - Audio waveform state
|
|
315
|
+
//
|
|
316
|
+
// AVAssetReader cannot read from remote URLs, so for remote audio we
|
|
317
|
+
// download to a temporary local file first (via URLSession.downloadTask).
|
|
318
|
+
// The local AVURLAsset is reused for zoom re-extractions, and the temp
|
|
319
|
+
// file is deleted in cleanupLocalAudioFile() on dismiss.
|
|
320
|
+
private let waveformView = AudioWaveformView()
|
|
321
|
+
private var lastKnownWaveformSize: CGSize = .zero
|
|
322
|
+
private var lastKnownWaveformRange: CMTimeRange = .zero
|
|
323
|
+
private var currentAssetReader: AVAssetReader?
|
|
324
|
+
/** AVURLAsset pointing to the downloaded local file (nil for local sources). */
|
|
325
|
+
private var localAudioAsset: AVURLAsset?
|
|
326
|
+
/** File URL of the temporary download, for deletion on cleanup. */
|
|
327
|
+
private var localAudioFileURL: URL?
|
|
328
|
+
private var audioDownloadTask: URLSessionDownloadTask?
|
|
329
|
+
|
|
274
330
|
private var impactFeedbackGenerator: UIImpactFeedbackGenerator?
|
|
275
331
|
private var didClampWhilePanning = false
|
|
276
332
|
|
|
277
333
|
|
|
334
|
+
/// Cancel all in-flight async work and delete temporary files.
|
|
335
|
+
/// This fires both on normal dismiss and on immediate close.
|
|
336
|
+
deinit {
|
|
337
|
+
generator?.cancelAllCGImageGeneration()
|
|
338
|
+
audioDownloadTask?.cancel()
|
|
339
|
+
currentAssetReader?.cancelReading()
|
|
340
|
+
cleanupLocalAudioFile()
|
|
341
|
+
}
|
|
342
|
+
|
|
278
343
|
// MARK: - Private
|
|
344
|
+
private func applyThemeColors() {
|
|
345
|
+
let bg: UIColor = isLightTheme ? .white : .black
|
|
346
|
+
let coverAlpha: CGFloat = isLightTheme ? 0.6 : 0.75
|
|
347
|
+
trackBackgroundColor = bg
|
|
348
|
+
thumbRestColor = bg
|
|
349
|
+
thumbnailLeadingCoverView.backgroundColor = UIColor(white: isLightTheme ? 1 : 0, alpha: coverAlpha)
|
|
350
|
+
thumbnailTrailingCoverView.backgroundColor = UIColor(white: isLightTheme ? 1 : 0, alpha: coverAlpha)
|
|
351
|
+
shadowView.layer.shadowColor = (isLightTheme ? UIColor.gray : UIColor.black).cgColor
|
|
352
|
+
}
|
|
353
|
+
|
|
279
354
|
private func setup() {
|
|
280
355
|
addSubview(thumbnailClipView)
|
|
281
356
|
thumbnailClipView.addSubview(thumbnailWrapperView)
|
|
282
357
|
thumbnailWrapperView.addSubview(leadingThumbRest)
|
|
283
358
|
thumbnailWrapperView.addSubview(trailingThumbRest)
|
|
284
359
|
thumbnailWrapperView.addSubview(thumbnailTrackView)
|
|
360
|
+
|
|
361
|
+
// Waveform view sits inside the thumbnail track but starts hidden;
|
|
362
|
+
// it's shown only for audio files once data is available.
|
|
363
|
+
waveformView.backgroundColor = waveformBgColor
|
|
364
|
+
waveformView.barColor = waveformBarColor
|
|
365
|
+
waveformView.barWidth = waveformBarWidth
|
|
366
|
+
waveformView.barGap = waveformBarGap
|
|
367
|
+
waveformView.barCornerRadius = waveformBarCornerRadius
|
|
368
|
+
waveformView.isHidden = true
|
|
369
|
+
thumbnailTrackView.addSubview(waveformView)
|
|
370
|
+
|
|
285
371
|
thumbnailWrapperView.addSubview(thumbnailLeadingCoverView)
|
|
286
372
|
thumbnailWrapperView.addSubview(thumbnailTrailingCoverView)
|
|
287
373
|
|
|
288
|
-
progressIndicator.backgroundColor = .white
|
|
289
|
-
progressIndicator.layer.shadowColor = UIColor.black.cgColor
|
|
290
374
|
progressIndicator.layer.shadowOffset = .zero
|
|
291
375
|
progressIndicator.layer.shadowRadius = 2
|
|
292
376
|
progressIndicator.layer.shadowOpacity = 0.25
|
|
@@ -302,13 +386,7 @@ import AVFoundation
|
|
|
302
386
|
|
|
303
387
|
thumbnailClipView.clipsToBounds = true
|
|
304
388
|
thumbnailTrackView.clipsToBounds = true
|
|
305
|
-
thumbnailLeadingCoverView.backgroundColor = UIColor(white: 0, alpha: 0.75)
|
|
306
|
-
thumbnailTrailingCoverView.backgroundColor = UIColor(white: 0, alpha: 0.75)
|
|
307
|
-
|
|
308
|
-
leadingThumbRest.backgroundColor = thumbRestColor
|
|
309
|
-
trailingThumbRest.backgroundColor = thumbRestColor
|
|
310
389
|
|
|
311
|
-
thumbnailWrapperView.backgroundColor = trackBackgroundColor
|
|
312
390
|
thumbnailWrapperView.layer.cornerRadius = 6
|
|
313
391
|
thumbnailWrapperView.layer.cornerCurve = .continuous
|
|
314
392
|
|
|
@@ -320,11 +398,20 @@ import AVFoundation
|
|
|
320
398
|
trailingThumbRest.layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMaxXMinYCorner]
|
|
321
399
|
trailingThumbRest.layer.cornerCurve = .continuous
|
|
322
400
|
|
|
323
|
-
shadowView.layer.shadowColor = UIColor.black.cgColor
|
|
324
401
|
shadowView.layer.shadowOffset = .zero
|
|
325
402
|
shadowView.layer.shadowRadius = 2
|
|
326
403
|
shadowView.layer.shadowOpacity = 0.25
|
|
327
404
|
|
|
405
|
+
// Apply default (dark theme) colors — overridden by applyThemeColors() when asset is set
|
|
406
|
+
progressIndicator.backgroundColor = .white
|
|
407
|
+
progressIndicator.layer.shadowColor = UIColor.black.cgColor
|
|
408
|
+
thumbnailLeadingCoverView.backgroundColor = UIColor(white: 0, alpha: 0.75)
|
|
409
|
+
thumbnailTrailingCoverView.backgroundColor = UIColor(white: 0, alpha: 0.75)
|
|
410
|
+
leadingThumbRest.backgroundColor = thumbRestColor
|
|
411
|
+
trailingThumbRest.backgroundColor = thumbRestColor
|
|
412
|
+
thumbnailWrapperView.backgroundColor = trackBackgroundColor
|
|
413
|
+
shadowView.layer.shadowColor = UIColor.black.cgColor
|
|
414
|
+
|
|
328
415
|
setupConstraints()
|
|
329
416
|
setupGestures()
|
|
330
417
|
}
|
|
@@ -409,6 +496,7 @@ import AVFoundation
|
|
|
409
496
|
let transform = track.preferredTransform
|
|
410
497
|
let fixedSize = naturalSize.applyingVideoTransform(transform)
|
|
411
498
|
|
|
499
|
+
self.generator?.cancelAllCGImageGeneration()
|
|
412
500
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
413
501
|
generator.apertureMode = .cleanAperture
|
|
414
502
|
generator.videoComposition = videoComposition
|
|
@@ -462,6 +550,223 @@ import AVFoundation
|
|
|
462
550
|
}
|
|
463
551
|
}
|
|
464
552
|
|
|
553
|
+
/// Called from layoutSubviews whenever the view size or visible time range changes.
|
|
554
|
+
///
|
|
555
|
+
/// For remote URLs, the first call triggers a download; once the local
|
|
556
|
+
/// file is cached, subsequent calls (e.g. zoom) skip straight to reading.
|
|
557
|
+
private func regenerateWaveformIfNeeded() {
|
|
558
|
+
guard isAudioOnly else { return }
|
|
559
|
+
let size = bounds.size
|
|
560
|
+
guard size.width > 0 && size.height > 0 else { return }
|
|
561
|
+
guard lastKnownWaveformSize != size || !CMTimeRangeEqual(lastKnownWaveformRange, visibleRange) else { return }
|
|
562
|
+
guard let asset = asset else { return }
|
|
563
|
+
|
|
564
|
+
// Remote URL path: download once, then reuse localAudioAsset for reads
|
|
565
|
+
if let urlAsset = asset as? AVURLAsset, !urlAsset.url.isFileURL {
|
|
566
|
+
if let localAsset = localAudioAsset {
|
|
567
|
+
guard let audioTrack = localAsset.tracks(withMediaType: .audio).first else { return }
|
|
568
|
+
readWaveformSamples(from: localAsset, audioTrack: audioTrack, size: size)
|
|
569
|
+
} else if audioDownloadTask == nil {
|
|
570
|
+
downloadAudioForWaveform(from: urlAsset.url)
|
|
571
|
+
}
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Local file path: read directly
|
|
576
|
+
guard let audioTrack = asset.tracks(withMediaType: .audio).first else { return }
|
|
577
|
+
readWaveformSamples(from: asset, audioTrack: audioTrack, size: size)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/// Download remote audio to a temporary local file so AVAssetReader can read it.
|
|
581
|
+
///
|
|
582
|
+
/// The file extension is inferred from the HTTP response (Content-Disposition,
|
|
583
|
+
/// MIME type) or the original URL, because AVURLAsset on iOS relies on
|
|
584
|
+
/// the extension to identify the audio codec — a generic `.tmp` extension
|
|
585
|
+
/// would cause silent failures.
|
|
586
|
+
private func downloadAudioForWaveform(from url: URL) {
|
|
587
|
+
let task = URLSession.shared.downloadTask(with: url) { [weak self] tempURL, response, error in
|
|
588
|
+
guard let self = self, let tempURL = tempURL else {
|
|
589
|
+
print("AudioWaveform: Download failed: \(error?.localizedDescription ?? "unknown")")
|
|
590
|
+
DispatchQueue.main.async { self?.audioDownloadTask = nil }
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
let ext = Self.audioFileExtension(from: response, originalURL: url)
|
|
595
|
+
let destURL = FileManager.default.temporaryDirectory
|
|
596
|
+
.appendingPathComponent("waveform_\(UUID().uuidString).\(ext)")
|
|
597
|
+
do {
|
|
598
|
+
try FileManager.default.moveItem(at: tempURL, to: destURL)
|
|
599
|
+
let localAsset = AVURLAsset(url: destURL, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true])
|
|
600
|
+
localAsset.loadValuesAsynchronously(forKeys: ["tracks"]) {
|
|
601
|
+
var trackError: NSError?
|
|
602
|
+
let status = localAsset.statusOfValue(forKey: "tracks", error: &trackError)
|
|
603
|
+
guard status == .loaded else {
|
|
604
|
+
print("AudioWaveform: Failed to load tracks from downloaded file: \(trackError?.localizedDescription ?? "unknown")")
|
|
605
|
+
try? FileManager.default.removeItem(at: destURL)
|
|
606
|
+
DispatchQueue.main.async { self.audioDownloadTask = nil }
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
DispatchQueue.main.async {
|
|
610
|
+
self.localAudioFileURL = destURL
|
|
611
|
+
self.localAudioAsset = localAsset
|
|
612
|
+
self.audioDownloadTask = nil
|
|
613
|
+
self.lastKnownWaveformSize = .zero
|
|
614
|
+
self.setNeedsLayout()
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} catch {
|
|
618
|
+
print("AudioWaveform: Failed to move downloaded file: \(error)")
|
|
619
|
+
DispatchQueue.main.async { self.audioDownloadTask = nil }
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
audioDownloadTask = task
|
|
623
|
+
task.resume()
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/// Determine the correct audio file extension from the HTTP response.
|
|
627
|
+
/// Priority: Content-Disposition → MIME type → URL path extension → "m4a" fallback.
|
|
628
|
+
private static func audioFileExtension(from response: URLResponse?, originalURL: URL) -> String {
|
|
629
|
+
if let suggested = response?.suggestedFilename, !suggested.isEmpty {
|
|
630
|
+
let ext = (suggested as NSString).pathExtension
|
|
631
|
+
if !ext.isEmpty { return ext }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if let mimeType = response?.mimeType?.lowercased() {
|
|
635
|
+
switch mimeType {
|
|
636
|
+
case "audio/mpeg", "audio/mp3": return "mp3"
|
|
637
|
+
case "audio/mp4", "audio/x-m4a", "audio/aac": return "m4a"
|
|
638
|
+
case "audio/wav", "audio/x-wav", "audio/wave": return "wav"
|
|
639
|
+
case "audio/flac": return "flac"
|
|
640
|
+
case "audio/ogg", "audio/vorbis": return "ogg"
|
|
641
|
+
case "audio/aiff", "audio/x-aiff": return "aiff"
|
|
642
|
+
default: break
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let urlExt = originalURL.pathExtension
|
|
647
|
+
if !urlExt.isEmpty { return urlExt }
|
|
648
|
+
|
|
649
|
+
return "m4a"
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/// Decode PCM samples from the given asset's audio track and compute
|
|
653
|
+
/// per-bar RMS amplitudes normalised to [0, 1].
|
|
654
|
+
///
|
|
655
|
+
/// Runs the heavy decode on a background queue and posts the result
|
|
656
|
+
/// back to the main thread. The AVAssetReader is stored in
|
|
657
|
+
/// `currentAssetReader` so it can be cancelled if the editor is closed
|
|
658
|
+
/// or the view resizes mid-read.
|
|
659
|
+
private func readWaveformSamples(from asset: AVAsset, audioTrack: AVAssetTrack, size: CGSize) {
|
|
660
|
+
lastKnownWaveformSize = size
|
|
661
|
+
lastKnownWaveformRange = visibleRange
|
|
662
|
+
|
|
663
|
+
waveformView.isHidden = false
|
|
664
|
+
|
|
665
|
+
currentAssetReader?.cancelReading()
|
|
666
|
+
currentAssetReader = nil
|
|
667
|
+
|
|
668
|
+
let timeRange = visibleRange
|
|
669
|
+
let step = waveformBarWidth + waveformBarGap
|
|
670
|
+
let barCount = max(1, Int(floor(size.width / step)))
|
|
671
|
+
|
|
672
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
673
|
+
guard let self = self else { return }
|
|
674
|
+
|
|
675
|
+
let reader: AVAssetReader
|
|
676
|
+
do {
|
|
677
|
+
reader = try AVAssetReader(asset: asset)
|
|
678
|
+
} catch {
|
|
679
|
+
print("AudioWaveform: Failed to create AVAssetReader: \(error)")
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
reader.timeRange = timeRange
|
|
684
|
+
|
|
685
|
+
let outputSettings: [String: Any] = [
|
|
686
|
+
AVFormatIDKey: kAudioFormatLinearPCM,
|
|
687
|
+
AVLinearPCMIsFloatKey: true,
|
|
688
|
+
AVLinearPCMBitDepthKey: 32,
|
|
689
|
+
AVNumberOfChannelsKey: 1,
|
|
690
|
+
]
|
|
691
|
+
let output = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: outputSettings)
|
|
692
|
+
|
|
693
|
+
guard reader.canAdd(output) else {
|
|
694
|
+
print("AudioWaveform: Cannot add output to reader")
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
reader.add(output)
|
|
698
|
+
|
|
699
|
+
DispatchQueue.main.sync {
|
|
700
|
+
self.currentAssetReader = reader
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
guard reader.startReading() else {
|
|
704
|
+
print("AudioWaveform: Failed to start reading: \(String(describing: reader.error))")
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
var allSamples = [Float]()
|
|
709
|
+
allSamples.reserveCapacity(barCount * 512)
|
|
710
|
+
|
|
711
|
+
while reader.status == .reading {
|
|
712
|
+
guard let sampleBuffer = output.copyNextSampleBuffer() else { break }
|
|
713
|
+
guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { continue }
|
|
714
|
+
|
|
715
|
+
let length = CMBlockBufferGetDataLength(blockBuffer)
|
|
716
|
+
let sampleCount = length / MemoryLayout<Float>.size
|
|
717
|
+
guard sampleCount > 0 else { continue }
|
|
718
|
+
|
|
719
|
+
var data = [Float](repeating: 0, count: sampleCount)
|
|
720
|
+
CMBlockBufferCopyDataBytes(blockBuffer, atOffset: 0, dataLength: length, destination: &data)
|
|
721
|
+
allSamples.append(contentsOf: data)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
guard !allSamples.isEmpty else {
|
|
725
|
+
DispatchQueue.main.async {
|
|
726
|
+
self.waveformView.amplitudes = []
|
|
727
|
+
}
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
let samplesPerBar = max(1, allSamples.count / barCount)
|
|
732
|
+
var amplitudes = [CGFloat]()
|
|
733
|
+
amplitudes.reserveCapacity(barCount)
|
|
734
|
+
|
|
735
|
+
for i in 0..<barCount {
|
|
736
|
+
let start = i * samplesPerBar
|
|
737
|
+
let end = min(start + samplesPerBar, allSamples.count)
|
|
738
|
+
guard start < allSamples.count else {
|
|
739
|
+
amplitudes.append(0)
|
|
740
|
+
continue
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
var sumSquares: Float = 0
|
|
744
|
+
for j in start..<end {
|
|
745
|
+
let s = allSamples[j]
|
|
746
|
+
sumSquares += s * s
|
|
747
|
+
}
|
|
748
|
+
let rms = sqrt(sumSquares / Float(end - start))
|
|
749
|
+
amplitudes.append(CGFloat(rms))
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
let maxAmp = amplitudes.max() ?? 1
|
|
753
|
+
let normalizer: CGFloat = maxAmp > 0 ? 1.0 / maxAmp : 1.0
|
|
754
|
+
let normalized = amplitudes.map { min($0 * normalizer, 1.0) }
|
|
755
|
+
|
|
756
|
+
DispatchQueue.main.async {
|
|
757
|
+
self.waveformView.amplitudes = normalized
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/// Delete the temporary local audio file created by downloadAudioForWaveform.
|
|
763
|
+
private func cleanupLocalAudioFile() {
|
|
764
|
+
if let url = localAudioFileURL {
|
|
765
|
+
try? FileManager.default.removeItem(at: url)
|
|
766
|
+
localAudioFileURL = nil
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
465
770
|
private func timeForLocation(_ x: CGFloat) -> CMTime {
|
|
466
771
|
let size = bounds.size
|
|
467
772
|
let inset = thumbView.chevronWidth + horizontalInset
|
|
@@ -932,6 +1237,11 @@ import AVFoundation
|
|
|
932
1237
|
}
|
|
933
1238
|
|
|
934
1239
|
regenerateThumbnailsIfNeeded()
|
|
1240
|
+
regenerateWaveformIfNeeded()
|
|
1241
|
+
// Inset waveformView by the leading handle's chevron width so its
|
|
1242
|
+
// background doesn't bleed underneath the translucent handle area.
|
|
1243
|
+
let waveformLeft = thumbView.chevronWidth
|
|
1244
|
+
waveformView.frame = CGRect(x: waveformLeft, y: 0, width: max(0, thumbnailTrackView.bounds.width - waveformLeft), height: thumbnailTrackView.bounds.height)
|
|
935
1245
|
|
|
936
1246
|
for thumbnail in thumbnails {
|
|
937
1247
|
let position = locationForTime(thumbnail.time) - horizontalInset + thumbnailOffset
|