react-native-video-trim 7.1.0 → 8.0.0

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.
@@ -135,12 +135,25 @@ import AVFoundation
135
135
 
136
136
  var asset: AVAsset? {
137
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
+
138
149
  if let asset = asset {
139
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
  }
@@ -160,6 +173,34 @@ import AVFoundation
160
173
  var enableHapticFeedback = true
161
174
  var zoomOnWaitingDuration: Double = 5.0 // Default: 5 seconds
162
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
+ }
163
204
 
164
205
  // the available range of the asset.
165
206
  // Will be set to the full duration of the asset when assigning a new asset
@@ -270,10 +311,35 @@ import AVFoundation
270
311
  private var thumbnails = Array<Thumbnail>()
271
312
  private var generator: AVAssetImageGenerator?
272
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
+
273
330
  private var impactFeedbackGenerator: UIImpactFeedbackGenerator?
274
331
  private var didClampWhilePanning = false
275
332
 
276
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
+
277
343
  // MARK: - Private
278
344
  private func applyThemeColors() {
279
345
  let bg: UIColor = isLightTheme ? .white : .black
@@ -291,6 +357,17 @@ import AVFoundation
291
357
  thumbnailWrapperView.addSubview(leadingThumbRest)
292
358
  thumbnailWrapperView.addSubview(trailingThumbRest)
293
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
+
294
371
  thumbnailWrapperView.addSubview(thumbnailLeadingCoverView)
295
372
  thumbnailWrapperView.addSubview(thumbnailTrailingCoverView)
296
373
 
@@ -419,6 +496,7 @@ import AVFoundation
419
496
  let transform = track.preferredTransform
420
497
  let fixedSize = naturalSize.applyingVideoTransform(transform)
421
498
 
499
+ self.generator?.cancelAllCGImageGeneration()
422
500
  let generator = AVAssetImageGenerator(asset: asset)
423
501
  generator.apertureMode = .cleanAperture
424
502
  generator.videoComposition = videoComposition
@@ -472,6 +550,223 @@ import AVFoundation
472
550
  }
473
551
  }
474
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
+
475
770
  private func timeForLocation(_ x: CGFloat) -> CMTime {
476
771
  let size = bounds.size
477
772
  let inset = thumbView.chevronWidth + horizontalInset
@@ -942,6 +1237,11 @@ import AVFoundation
942
1237
  }
943
1238
 
944
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)
945
1245
 
946
1246
  for thumbnail in thumbnails {
947
1247
  let position = locationForTime(thumbnail.time) - horizontalInset + thumbnailOffset
@@ -52,8 +52,15 @@ class VideoTrimmerViewController: UIViewController {
52
52
  private var trimmerColor: UIColor = UIColor.systemYellow
53
53
  private var handleIconColor: UIColor = UIColor.black
54
54
  private var isLightTheme = false
55
+ private var waveformBarColor: UIColor = .white
56
+ private var waveformBgColor: UIColor = UIColor(red: 0.204, green: 0.471, blue: 0.965, alpha: 1)
57
+ private var waveformBarWidth: CGFloat = 3
58
+ private var waveformBarGap: CGFloat = 2
59
+ private var waveformBarCornerRadius: CGFloat = 1.5
55
60
  private var iconColor: UIColor { isLightTheme ? .black : .white }
56
61
  private var dimmedIconColor: UIColor { iconColor.withAlphaComponent(0.5) }
62
+ private let symbolConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
63
+ private let speedOptions: [Double] = [0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0]
57
64
 
58
65
  private let playerController = AVPlayerViewController()
59
66
  private var trimmer: VideoTrimmer!
@@ -81,6 +88,10 @@ class VideoTrimmerViewController: UIViewController {
81
88
 
82
89
  private(set) var rotationCount = 0
83
90
  private(set) var isFlipped = false
91
+ private(set) var isMuted = false
92
+ private var muteBtn: UIButton?
93
+ private(set) var speed: Double = 1.0
94
+ private var speedBtn: UIButton?
84
95
  private var isVideoType = true
85
96
  private var playerContainerView: UIView!
86
97
  private var transformStackView: UIStackView?
@@ -243,6 +254,11 @@ class VideoTrimmerViewController: UIViewController {
243
254
 
244
255
  playerController.player = nil
245
256
  playerController.dismiss(animated: false, completion: nil)
257
+
258
+ // Setting asset to nil triggers VideoTrimmer.asset.didSet, which
259
+ // cancels all in-flight work (thumbnail generation, waveform reader,
260
+ // audio download) and deletes temporary files.
261
+ trimmer?.asset = nil
246
262
  }
247
263
 
248
264
  public func pausePlayer() {
@@ -260,6 +276,7 @@ class VideoTrimmerViewController: UIViewController {
260
276
  }
261
277
 
262
278
  player.play()
279
+ player.rate = Float(speed)
263
280
  }
264
281
 
265
282
  setPlayBtnIcon()
@@ -389,6 +406,12 @@ class VideoTrimmerViewController: UIViewController {
389
406
  private func setupVideoTrimmer() {
390
407
  trimmer = VideoTrimmer()
391
408
  trimmer.isLightTheme = isLightTheme
409
+ trimmer.waveformBarColor = waveformBarColor
410
+ trimmer.waveformBgColor = waveformBgColor
411
+ trimmer.waveformBarWidth = waveformBarWidth
412
+ trimmer.waveformBarGap = waveformBarGap
413
+ trimmer.waveformBarCornerRadius = waveformBarCornerRadius
414
+ trimmer.isAudioOnly = !isVideoType
392
415
  trimmer.asset = asset
393
416
  trimmer.minimumDuration = CMTime(seconds: 1, preferredTimescale: 600)
394
417
  trimmer.enableHapticFeedback = enableHapticFeedback
@@ -447,6 +470,7 @@ class VideoTrimmerViewController: UIViewController {
447
470
  }
448
471
  playerController.player = AVPlayer()
449
472
  player.replaceCurrentItem(with: AVPlayerItem(asset: asset))
473
+ player.isMuted = isMuted
450
474
 
451
475
  statusObservation = player.observe(\.status, options: [.new, .initial]) { [weak self] player, _ in
452
476
  DispatchQueue.main.async {
@@ -505,8 +529,6 @@ class VideoTrimmerViewController: UIViewController {
505
529
  private func setupTransformButtons() {
506
530
  guard isVideoType else { return }
507
531
 
508
- let symbolConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
509
-
510
532
  let flipBtn = UIButton(type: .system)
511
533
  flipBtn.setImage(UIImage(systemName: "arrow.trianglehead.left.and.right.righttriangle.left.righttriangle.right", withConfiguration: symbolConfig), for: .normal)
512
534
  flipBtn.tintColor = iconColor
@@ -523,6 +545,25 @@ class VideoTrimmerViewController: UIViewController {
523
545
  cropButton.addTarget(self, action: #selector(onCropTapped), for: .touchUpInside)
524
546
  self.cropBtn = cropButton
525
547
 
548
+ let muteButton = UIButton(type: .system)
549
+ muteButton.setImage(UIImage(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", withConfiguration: symbolConfig), for: .normal)
550
+ muteButton.tintColor = iconColor
551
+ muteButton.addTarget(self, action: #selector(onMuteTapped), for: .touchUpInside)
552
+ self.muteBtn = muteButton
553
+
554
+ let speedButton = UIButton(type: .system)
555
+ let speedLabel = speed == 1.0 ? "1x" : "\(speed)x"
556
+ speedButton.setTitle(speedLabel, for: .normal)
557
+ speedButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
558
+ speedButton.tintColor = iconColor
559
+ if #available(iOS 14.0, *) {
560
+ speedButton.showsMenuAsPrimaryAction = true
561
+ speedButton.menu = buildSpeedMenu()
562
+ } else {
563
+ speedButton.addTarget(self, action: #selector(onSpeedTapped), for: .touchUpInside)
564
+ }
565
+ self.speedBtn = speedButton
566
+
526
567
  let undoButton = UIButton(type: .system)
527
568
  undoButton.setImage(UIImage(systemName: "arrow.uturn.backward", withConfiguration: symbolConfig), for: .normal)
528
569
  undoButton.tintColor = dimmedIconColor
@@ -537,7 +578,7 @@ class VideoTrimmerViewController: UIViewController {
537
578
  redoButton.addTarget(self, action: #selector(onRedoTapped), for: .touchUpInside)
538
579
  self.redoBtn = redoButton
539
580
 
540
- let leftStack = UIStackView(arrangedSubviews: [flipBtn, rotateBtn, cropButton])
581
+ let leftStack = UIStackView(arrangedSubviews: [flipBtn, rotateBtn, cropButton, muteButton, speedButton])
541
582
  leftStack.axis = .horizontal
542
583
  leftStack.spacing = 12
543
584
 
@@ -562,6 +603,10 @@ class VideoTrimmerViewController: UIViewController {
562
603
  rotateBtn.heightAnchor.constraint(equalToConstant: btnSize),
563
604
  cropButton.widthAnchor.constraint(equalToConstant: btnSize),
564
605
  cropButton.heightAnchor.constraint(equalToConstant: btnSize),
606
+ muteButton.widthAnchor.constraint(equalToConstant: btnSize),
607
+ muteButton.heightAnchor.constraint(equalToConstant: btnSize),
608
+ speedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 36),
609
+ speedButton.heightAnchor.constraint(equalToConstant: btnSize),
565
610
  undoButton.widthAnchor.constraint(equalToConstant: btnSize),
566
611
  undoButton.heightAnchor.constraint(equalToConstant: btnSize),
567
612
  redoButton.widthAnchor.constraint(equalToConstant: btnSize),
@@ -574,6 +619,66 @@ class VideoTrimmerViewController: UIViewController {
574
619
  self.transformStackView = fullRow
575
620
  }
576
621
 
622
+ @objc private func onMuteTapped() {
623
+ isMuted.toggle()
624
+ muteBtn?.setImage(UIImage(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", withConfiguration: symbolConfig), for: .normal)
625
+ player.isMuted = isMuted
626
+ if enableHapticFeedback {
627
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
628
+ }
629
+ }
630
+
631
+ // Builds a native context menu for speed selection (iOS 14+). The menu is
632
+ // attached via showsMenuAsPrimaryAction so it opens on tap without an extra
633
+ // long-press gesture. Falls back to UIAlertController on older iOS versions.
634
+ @available(iOS 14.0, *)
635
+ private func buildSpeedMenu() -> UIMenu {
636
+ let actions = speedOptions.map { opt in
637
+ let title = opt == 1.0 ? "Normal (1x)" : "\(opt)x"
638
+ let isSelected = abs(opt - speed) < 0.0001
639
+ return UIAction(title: title, state: isSelected ? .on : .off) { [weak self] _ in
640
+ self?.setSpeed(opt)
641
+ }
642
+ }
643
+ return UIMenu(title: "", children: actions)
644
+ }
645
+
646
+ /// Fallback for iOS < 14 where UIMenu is unavailable.
647
+ @objc private func onSpeedTapped() {
648
+ let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
649
+ alert.overrideUserInterfaceStyle = isLightTheme ? .light : .dark
650
+ for opt in speedOptions {
651
+ let title = opt == 1.0 ? "Normal (1x)" : "\(opt)x"
652
+ let isSelected = abs(opt - speed) < 0.0001
653
+ let action = UIAlertAction(title: title, style: isSelected ? .destructive : .default) { [weak self] _ in
654
+ self?.setSpeed(opt)
655
+ }
656
+ alert.addAction(action)
657
+ }
658
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
659
+ if let pop = alert.popoverPresentationController, let btn = speedBtn {
660
+ pop.sourceView = btn
661
+ pop.sourceRect = btn.bounds
662
+ }
663
+ present(alert, animated: true)
664
+ }
665
+
666
+ private func setSpeed(_ newSpeed: Double) {
667
+ speed = newSpeed
668
+ let label = newSpeed == 1.0 ? "1x" : "\(newSpeed)x"
669
+ speedBtn?.setTitle(label, for: .normal)
670
+ if #available(iOS 14.0, *) {
671
+ speedBtn?.menu = buildSpeedMenu()
672
+ }
673
+ player.rate = Float(newSpeed)
674
+ if player.timeControlStatus != .playing {
675
+ player.pause()
676
+ }
677
+ if enableHapticFeedback {
678
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
679
+ }
680
+ }
681
+
577
682
  @objc private func onFlipTapped() {
578
683
  pushUndo()
579
684
  isFlipped.toggle()
@@ -962,6 +1067,10 @@ class VideoTrimmerViewController: UIViewController {
962
1067
  zoomOnWaitingDuration = (config["zoomOnWaitingDuration"] as? Double ?? 5.0) / 1000.0 // convert ms to s
963
1068
  autoplay = config["autoplay"] as? Bool ?? false
964
1069
  isVideoType = (config["type"] as? String ?? "video") == "video"
1070
+ isMuted = config["removeAudio"] as? Bool ?? false
1071
+ if let cfgSpeed = config["speed"] as? Double {
1072
+ speed = cfgSpeed
1073
+ }
965
1074
  headerText = config["headerText"] as? String
966
1075
  headerTextSize = config["headerTextSize"] as? Int ?? 16
967
1076
  headerTextColor = config["headerTextColor"] as? Double
@@ -972,6 +1081,21 @@ class VideoTrimmerViewController: UIViewController {
972
1081
  if let handleIconColorValue = config["handleIconColor"] as? Double {
973
1082
  handleIconColor = RCTConvert.uiColor(handleIconColorValue) ?? (isLightTheme ? .white : .black)
974
1083
  }
1084
+ if let v = config["waveformColor"] as? Double {
1085
+ waveformBarColor = RCTConvert.uiColor(v) ?? .white
1086
+ }
1087
+ if let v = config["waveformBackgroundColor"] as? Double {
1088
+ waveformBgColor = RCTConvert.uiColor(v) ?? UIColor(red: 0.204, green: 0.471, blue: 0.965, alpha: 1)
1089
+ }
1090
+ if let v = config["waveformBarWidth"] as? Double, v > 0 {
1091
+ waveformBarWidth = CGFloat(v)
1092
+ }
1093
+ if let v = config["waveformBarGap"] as? Double, v >= 0 {
1094
+ waveformBarGap = CGFloat(v)
1095
+ }
1096
+ if let v = config["waveformBarCornerRadius"] as? Double, v >= 0 {
1097
+ waveformBarCornerRadius = CGFloat(v)
1098
+ }
975
1099
  }
976
1100
 
977
1101
  private func onPlayerReady() {
@@ -992,7 +1116,8 @@ class VideoTrimmerViewController: UIViewController {
992
1116
 
993
1117
  if jumpToPositionOnLoad > 0 {
994
1118
  let duration = (asset?.duration.seconds ?? 0) * 1000
995
- let time = jumpToPositionOnLoad > duration ? duration : jumpToPositionOnLoad
1119
+ let endMs = trimmer.selectedRange.end.seconds * 1000
1120
+ let time = min(jumpToPositionOnLoad, min(duration, endMs))
996
1121
  let cmtime = CMTime(value: CMTimeValue(time), timescale: 1000)
997
1122
 
998
1123
  self.seek(to: cmtime)
@@ -22,6 +22,58 @@ import { TurboModuleRegistry } from 'react-native';
22
22
  * Result returned by a trim operation (both editor and headless).
23
23
  */
24
24
 
25
+ /**
26
+ * Options for extracting a single frame from a video.
27
+ */
28
+
29
+ /**
30
+ * Result returned by {@link Spec.getFrameAt}.
31
+ */
32
+
33
+ /**
34
+ * Options for extracting the audio track from a video file.
35
+ */
36
+
37
+ /**
38
+ * Result returned by {@link Spec.extractAudio}.
39
+ */
40
+
41
+ /**
42
+ * Options for compressing a video file.
43
+ */
44
+
45
+ /**
46
+ * Result returned by {@link Spec.compress}.
47
+ */
48
+
49
+ /**
50
+ * Options for converting a video segment to an animated GIF.
51
+ */
52
+
53
+ /**
54
+ * Result returned by {@link Spec.toGif}.
55
+ */
56
+
57
+ /**
58
+ * Options for merging multiple media files into one.
59
+ */
60
+
61
+ /**
62
+ * Result returned by {@link Spec.merge}.
63
+ */
64
+
65
+ /**
66
+ * Result returned by {@link Spec.saveToPhoto}.
67
+ */
68
+
69
+ /**
70
+ * Result returned by {@link Spec.saveToDocuments}.
71
+ */
72
+
73
+ /**
74
+ * Result returned by {@link Spec.share}.
75
+ */
76
+
25
77
  /**
26
78
  * TurboModule spec for the native VideoTrim module.
27
79
  */
@@ -1 +1 @@
1
- {"version":3,"names":["TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeVideoTrim.ts"],"mappings":";;AACA,SAASA,mBAAmB,QAAQ,cAAc;;AAGlD;AACA;AACA;;AAwBA;AACA;AACA;;AAqGA;AACA;AACA;;AAQA;AACA;AACA;;AAUA;AACA;AACA;;AAcA;AACA;AACA;;AAmEA,eAAeA,mBAAmB,CAACC,YAAY,CAAO,WAAW,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeVideoTrim.ts"],"mappings":";;AACA,SAASA,mBAAmB,QAAQ,cAAc;;AAGlD;AACA;AACA;;AAgCA;AACA;AACA;;AA+GA;AACA;AACA;;AAQA;AACA;AACA;;AAUA;AACA;AACA;;AAcA;AACA;AACA;;AAcA;AACA;AACA;;AAMA;AACA;AACA;;AAMA;AACA;AACA;;AAQA;AACA;AACA;;AAqBA;AACA;AACA;;AAMA;AACA;AACA;;AAYA;AACA;AACA;;AAMA;AACA;AACA;;AAMA;AACA;AACA;;AAQA;AACA;AACA;;AAMA;AACA;AACA;;AAMA;AACA;AACA;;AAMA;AACA;AACA;;AAmFA,eAAeA,mBAAmB,CAACC,YAAY,CAAO,WAAW,CAAC","ignoreList":[]}