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.
- package/README.md +287 -6
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +488 -34
- package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +95 -36
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +39 -17
- package/android/src/main/java/com/videotrim/widgets/AudioWaveformView.kt +92 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +579 -8
- package/android/src/main/res/drawable/speaker_slash_fill.xml +19 -0
- package/android/src/main/res/drawable/speaker_wave_2_fill.xml +23 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +22 -0
- package/android/src/newarch/VideoTrimModule.kt +33 -0
- package/android/src/oldarch/VideoTrimModule.kt +41 -0
- package/android/src/oldarch/VideoTrimSpec.kt +17 -0
- package/ios/AudioWaveformView.swift +75 -0
- package/ios/VideoTrim.mm +180 -1
- package/ios/VideoTrim.swift +632 -39
- package/ios/VideoTrimmer.swift +300 -0
- package/ios/VideoTrimmerViewController.swift +129 -4
- package/lib/module/NativeVideoTrim.js +52 -0
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +155 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +158 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +65 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +171 -0
- package/src/index.tsx +211 -2
package/ios/VideoTrimmer.swift
CHANGED
|
@@ -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
|
|
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;;
|
|
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":[]}
|