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.
@@ -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
- ctx.saveGState()
77
- let fullPath = UIBezierPath(rect: bounds)
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(UIColor.white.cgColor)
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
 
@@ -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)
@@ -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