react-native-waveform-player 0.0.1 → 1.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.
Files changed (46) hide show
  1. package/AudioWaveform.podspec +29 -0
  2. package/LICENSE +20 -0
  3. package/README.md +296 -0
  4. package/android/build.gradle +67 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/com/audiowaveform/AudioPlayerEngine.kt +353 -0
  7. package/android/src/main/java/com/audiowaveform/AudioWaveformEvent.kt +22 -0
  8. package/android/src/main/java/com/audiowaveform/AudioWaveformPackage.kt +17 -0
  9. package/android/src/main/java/com/audiowaveform/AudioWaveformView.kt +715 -0
  10. package/android/src/main/java/com/audiowaveform/AudioWaveformViewManager.kt +234 -0
  11. package/android/src/main/java/com/audiowaveform/PlayPauseButton.kt +106 -0
  12. package/android/src/main/java/com/audiowaveform/SpeedPillView.kt +70 -0
  13. package/android/src/main/java/com/audiowaveform/WaveformBarsView.kt +358 -0
  14. package/android/src/main/java/com/audiowaveform/WaveformDecoder.kt +240 -0
  15. package/android/src/main/res/drawable/pause_fill.xml +15 -0
  16. package/android/src/main/res/drawable/play_fill.xml +15 -0
  17. package/ios/AudioPlayerEngine.swift +281 -0
  18. package/ios/AudioWaveformView.h +14 -0
  19. package/ios/AudioWaveformView.mm +307 -0
  20. package/ios/AudioWaveformViewImpl.swift +835 -0
  21. package/ios/PlayPauseButton.swift +118 -0
  22. package/ios/SpeedPillView.swift +70 -0
  23. package/ios/WaveformBarsView.swift +327 -0
  24. package/ios/WaveformDecoder.swift +332 -0
  25. package/lib/module/AudioWaveformView.js +8 -0
  26. package/lib/module/AudioWaveformView.js.map +1 -0
  27. package/lib/module/AudioWaveformView.native.js +79 -0
  28. package/lib/module/AudioWaveformView.native.js.map +1 -0
  29. package/lib/module/AudioWaveformViewNativeComponent.ts +95 -0
  30. package/lib/module/index.js +4 -0
  31. package/lib/module/index.js.map +1 -0
  32. package/lib/module/package.json +1 -0
  33. package/lib/typescript/package.json +1 -0
  34. package/lib/typescript/src/AudioWaveformView.d.ts +233 -0
  35. package/lib/typescript/src/AudioWaveformView.d.ts.map +1 -0
  36. package/lib/typescript/src/AudioWaveformView.native.d.ts +335 -0
  37. package/lib/typescript/src/AudioWaveformView.native.d.ts.map +1 -0
  38. package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts +71 -0
  39. package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts.map +1 -0
  40. package/lib/typescript/src/index.d.ts +3 -0
  41. package/lib/typescript/src/index.d.ts.map +1 -0
  42. package/package.json +138 -7
  43. package/src/AudioWaveformView.native.tsx +281 -0
  44. package/src/AudioWaveformView.tsx +96 -0
  45. package/src/AudioWaveformViewNativeComponent.ts +95 -0
  46. package/src/index.tsx +13 -0
@@ -0,0 +1,118 @@
1
+ import UIKit
2
+
3
+ /// Play/pause button rendered with the platform-built-in SF Symbols
4
+ /// `play.fill` / `pause.fill`. Tintable via `iconColor`.
5
+ ///
6
+ /// While `isLoading` is `true`, the icon is hidden and a native
7
+ /// `UIActivityIndicatorView` is shown in its place. The control stays
8
+ /// hit-testable so callers can queue a "play once ready" intent.
9
+ final class PlayPauseButton: UIControl {
10
+
11
+ var isPlaying: Bool = false {
12
+ didSet {
13
+ guard oldValue != isPlaying else { return }
14
+ // Don't crossfade between play / pause symbols while the icon
15
+ // is hidden behind the spinner — the user can't see it anyway,
16
+ // and once `isLoading` flips back to `false` the imageView is
17
+ // already showing the latest icon, so no animation is needed.
18
+ updateImage(animated: !isLoading)
19
+ }
20
+ }
21
+
22
+ var isLoading: Bool = false {
23
+ didSet {
24
+ guard oldValue != isLoading else { return }
25
+ updateLoadingState()
26
+ }
27
+ }
28
+
29
+ var iconColor: UIColor = .white {
30
+ didSet {
31
+ imageView.tintColor = iconColor
32
+ activityIndicator.color = iconColor
33
+ }
34
+ }
35
+
36
+ private let imageView = UIImageView()
37
+ private let activityIndicator: UIActivityIndicatorView = {
38
+ let style: UIActivityIndicatorView.Style
39
+ if #available(iOS 13.0, *) {
40
+ style = .medium
41
+ } else {
42
+ style = .white
43
+ }
44
+ let view = UIActivityIndicatorView(style: style)
45
+ view.hidesWhenStopped = true
46
+ return view
47
+ }()
48
+
49
+ override init(frame: CGRect) {
50
+ super.init(frame: frame)
51
+ commonInit()
52
+ }
53
+
54
+ required init?(coder: NSCoder) {
55
+ super.init(coder: coder)
56
+ commonInit()
57
+ }
58
+
59
+ private func commonInit() {
60
+ backgroundColor = .clear
61
+ imageView.tintColor = iconColor
62
+ imageView.contentMode = .scaleAspectFit
63
+ addSubview(imageView)
64
+ activityIndicator.color = iconColor
65
+ addSubview(activityIndicator)
66
+ updateImage(animated: false)
67
+ }
68
+
69
+ override func layoutSubviews() {
70
+ super.layoutSubviews()
71
+ // Size the symbol to ~70% of the button so taps stay generous.
72
+ let inset: CGFloat = 4
73
+ imageView.frame = bounds.insetBy(dx: inset, dy: inset)
74
+ activityIndicator.frame = imageView.frame
75
+ updateImage(animated: false)
76
+ }
77
+
78
+ private func updateImage(animated: Bool) {
79
+ let dim = min(imageView.bounds.width, imageView.bounds.height)
80
+ let pointSize = max(8, dim * 0.95)
81
+ let config = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)
82
+ let symbolName = isPlaying ? "pause.fill" : "play.fill"
83
+ let image = UIImage(systemName: symbolName, withConfiguration: config)?
84
+ .withRenderingMode(.alwaysTemplate)
85
+ if animated {
86
+ UIView.transition(with: imageView, duration: 0.12, options: .transitionCrossDissolve) {
87
+ self.imageView.image = image
88
+ }
89
+ } else {
90
+ imageView.image = image
91
+ }
92
+ }
93
+
94
+ private func updateLoadingState() {
95
+ if isLoading {
96
+ imageView.isHidden = true
97
+ activityIndicator.startAnimating()
98
+ } else {
99
+ imageView.isHidden = false
100
+ activityIndicator.stopAnimating()
101
+ }
102
+ }
103
+
104
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
105
+ super.touchesBegan(touches, with: event)
106
+ UIView.animate(withDuration: 0.08) { self.alpha = 0.6 }
107
+ }
108
+
109
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
110
+ super.touchesEnded(touches, with: event)
111
+ UIView.animate(withDuration: 0.12) { self.alpha = 1.0 }
112
+ }
113
+
114
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
115
+ super.touchesCancelled(touches, with: event)
116
+ UIView.animate(withDuration: 0.12) { self.alpha = 1.0 }
117
+ }
118
+ }
@@ -0,0 +1,70 @@
1
+ import UIKit
2
+
3
+ /// Rounded "1.5x" speed-rate label. Tap-to-cycle is wired up via
4
+ /// `onTap`; the parent (`AudioWaveformViewImpl`) is responsible for
5
+ /// applying the new rate to the audio engine.
6
+ final class SpeedPillView: UIView {
7
+
8
+ var label: UILabel = UILabel()
9
+
10
+ var pillColor: UIColor = UIColor.white.withAlphaComponent(0.25) {
11
+ didSet { backgroundColor = pillColor }
12
+ }
13
+
14
+ var textColor: UIColor = .white {
15
+ didSet { label.textColor = textColor }
16
+ }
17
+
18
+ var onTap: (() -> Void)?
19
+
20
+ override init(frame: CGRect) {
21
+ super.init(frame: frame)
22
+ commonInit()
23
+ }
24
+
25
+ required init?(coder: NSCoder) {
26
+ super.init(coder: coder)
27
+ commonInit()
28
+ }
29
+
30
+ private func commonInit() {
31
+ backgroundColor = pillColor
32
+ layer.masksToBounds = true
33
+ label.textColor = textColor
34
+ label.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
35
+ label.textAlignment = .center
36
+ label.adjustsFontSizeToFitWidth = true
37
+ label.minimumScaleFactor = 0.8
38
+ addSubview(label)
39
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
40
+ addGestureRecognizer(tap)
41
+ isUserInteractionEnabled = true
42
+ }
43
+
44
+ func setSpeed(_ speed: Float) {
45
+ // Format like "1x" / "1.5x" / "0.5x" — drop trailing ".0".
46
+ let rounded = (speed * 10).rounded() / 10
47
+ let isInt = rounded == floor(rounded)
48
+ let text: String = isInt
49
+ ? "\(Int(rounded))x"
50
+ : String(format: "%.1fx", rounded)
51
+ label.text = text
52
+ }
53
+
54
+ override func layoutSubviews() {
55
+ super.layoutSubviews()
56
+ label.frame = bounds.insetBy(dx: 6, dy: 0)
57
+ layer.cornerRadius = bounds.height / 2
58
+ }
59
+
60
+ @objc private func handleTap() {
61
+ UIView.animate(withDuration: 0.08, animations: { self.alpha = 0.6 }) { _ in
62
+ UIView.animate(withDuration: 0.12) { self.alpha = 1.0 }
63
+ }
64
+ onTap?()
65
+ }
66
+
67
+ override var intrinsicContentSize: CGSize {
68
+ return CGSize(width: 44, height: 22)
69
+ }
70
+ }
@@ -0,0 +1,327 @@
1
+ import UIKit
2
+
3
+ /// Custom UIView that draws the audio waveform as a row of vertical rounded-rect
4
+ /// bars with a "played" / "unplayed" two-tone fill.
5
+ ///
6
+ /// Drawing model:
7
+ /// 1. Build a single cached `UIBezierPath` containing every bar's rounded-rect
8
+ /// whenever the amplitudes / size / bar geometry change.
9
+ /// 2. On every `draw(_:)`:
10
+ /// a. Fill the cached path in `unplayedBarColor`.
11
+ /// b. Save state -> clip to `[0, progressX, bounds.height]` ->
12
+ /// fill the same cached path in `playedBarColor` -> restore state.
13
+ /// The bar straddling the playhead is partial-highlighted naturally because
14
+ /// the rounded-rect geometry is identical in both passes; only the right
15
+ /// edge is cropped by the clip.
16
+ ///
17
+ /// Touch handling is **immediate** (no slop, no long-press timeout): scrubbing
18
+ /// starts on `touchesBegan` and the host view receives the proportional position
19
+ /// via the `onScrubBegan/Moved/Ended` callbacks.
20
+ final class WaveformBarsView: UIView {
21
+
22
+ // MARK: - Visual configuration (set by AudioWaveformViewImpl)
23
+
24
+ var amplitudes: [CGFloat] = [] {
25
+ didSet { setTargetAmplitudes(amplitudes) }
26
+ }
27
+
28
+ var playedBarColor: UIColor = .white {
29
+ didSet { setNeedsDisplay() }
30
+ }
31
+
32
+ var unplayedBarColor: UIColor = UIColor.white.withAlphaComponent(0.5) {
33
+ didSet { setNeedsDisplay() }
34
+ }
35
+
36
+ var barWidth: CGFloat = 3 {
37
+ didSet { invalidatePath() }
38
+ }
39
+
40
+ var barGap: CGFloat = 2 {
41
+ didSet { invalidatePath() }
42
+ }
43
+
44
+ /// `< 0` means "auto" = barWidth / 2.
45
+ var barRadius: CGFloat = -1 {
46
+ didSet { invalidatePath() }
47
+ }
48
+
49
+ /// `<= 0` means "auto from view width".
50
+ var barCountOverride: Int = 0 {
51
+ didSet { invalidatePath() }
52
+ }
53
+
54
+ /// Fraction of the waveform that has been played, in [0, 1].
55
+ /// Updated at ~30 Hz while playing. The cached bar path makes the
56
+ /// per-frame redraw essentially free, so we always invalidate.
57
+ var progressFraction: CGFloat = 0 {
58
+ didSet {
59
+ if oldValue != progressFraction {
60
+ setNeedsDisplay()
61
+ }
62
+ }
63
+ }
64
+
65
+ // MARK: - Touch / scrub callbacks
66
+
67
+ /// All callbacks pass a fraction in [0, 1] (the touch's x divided by
68
+ /// the view's width).
69
+ var onScrubBegan: ((CGFloat) -> Void)?
70
+ var onScrubMoved: ((CGFloat) -> Void)?
71
+ var onScrubEnded: ((CGFloat, _ cancelled: Bool) -> Void)?
72
+
73
+ // MARK: - Private state
74
+
75
+ private var cachedPath: UIBezierPath?
76
+ private var cachedSize: CGSize = .zero
77
+
78
+ /// What's actually drawn this frame. During an amplitudes update, these
79
+ /// values smoothly interpolate between `startAmps` and `targetAmps`.
80
+ private var displayedAmps: [CGFloat] = []
81
+ private var startAmps: [CGFloat] = []
82
+ private var targetAmps: [CGFloat] = []
83
+ private var amplitudeAnimationStart: CFTimeInterval = 0
84
+ private var amplitudeDisplayLink: CADisplayLink?
85
+ /// How long each new partial-amplitudes update animates for.
86
+ private static let amplitudeAnimationDuration: CFTimeInterval = 0.2
87
+
88
+ // MARK: - Init
89
+
90
+ private lazy var scrubRecognizer: UILongPressGestureRecognizer = {
91
+ // `minimumPressDuration = 0` makes the gesture activate immediately on
92
+ // touch-down. `allowableMovement = .greatestFiniteMagnitude` keeps it
93
+ // alive through any drag distance.
94
+ //
95
+ // Long-press is the canonical iOS pattern for "claim the touch
96
+ // immediately so a parent UIScrollView can't cancel it" — once a
97
+ // long-press recognizer has begun, UIScrollView's pan can no longer
98
+ // hijack the touch sequence (which is what was causing scrubbing to
99
+ // fail when the bars view was nested inside a ScrollView).
100
+ let r = UILongPressGestureRecognizer(target: self, action: #selector(handleScrubGesture(_:)))
101
+ r.minimumPressDuration = 0
102
+ r.allowableMovement = .greatestFiniteMagnitude
103
+ r.cancelsTouchesInView = false
104
+ return r
105
+ }()
106
+
107
+ override init(frame: CGRect) {
108
+ super.init(frame: frame)
109
+ commonInit()
110
+ }
111
+
112
+ required init?(coder: NSCoder) {
113
+ super.init(coder: coder)
114
+ commonInit()
115
+ }
116
+
117
+ private func commonInit() {
118
+ isOpaque = false
119
+ backgroundColor = .clear
120
+ contentMode = .redraw
121
+ isUserInteractionEnabled = true
122
+ addGestureRecognizer(scrubRecognizer)
123
+ }
124
+
125
+ deinit {
126
+ amplitudeDisplayLink?.invalidate()
127
+ amplitudeDisplayLink = nil
128
+ }
129
+
130
+ // MARK: - Layout / path caching
131
+
132
+ override func layoutSubviews() {
133
+ super.layoutSubviews()
134
+ if cachedSize != bounds.size {
135
+ invalidatePath()
136
+ }
137
+ }
138
+
139
+ private func invalidatePath() {
140
+ cachedPath = nil
141
+ cachedSize = .zero
142
+ setNeedsDisplay()
143
+ }
144
+
145
+ private func ensureCachedPath() -> UIBezierPath? {
146
+ if let path = cachedPath, cachedSize == bounds.size {
147
+ return path
148
+ }
149
+ let path = buildBarPath()
150
+ cachedPath = path
151
+ cachedSize = bounds.size
152
+ return path
153
+ }
154
+
155
+ /// Uniform amplitude used to render the "loading" skeleton when no real
156
+ /// amplitudes have been decoded yet. Small enough to read clearly as a
157
+ /// placeholder, big enough to be obviously visible.
158
+ private static let placeholderAmplitude: CGFloat = 0.2
159
+
160
+ /// Build a composite `UIBezierPath` of every bar's rounded-rect. This is
161
+ /// rebuilt whenever amplitudes / size / bar geometry change (and on every
162
+ /// frame while an amplitude animation is running), so the hot 30 Hz
163
+ /// playback redraw can reuse the cached path.
164
+ ///
165
+ /// When no amplitudes have ever been delivered (decoder hasn't produced
166
+ /// the first partial yet) we render a uniform low-amplitude skeleton so
167
+ /// the user sees the bar pattern immediately instead of an empty card.
168
+ private func buildBarPath() -> UIBezierPath? {
169
+ let totalWidth = bounds.width
170
+ let totalHeight = bounds.height
171
+ guard totalWidth > 0, totalHeight > 0 else { return nil }
172
+
173
+ let step = barWidth + barGap
174
+ guard step > 0 else { return nil }
175
+
176
+ let autoCount = Int(floor(totalWidth / step))
177
+ let barCount = barCountOverride > 0
178
+ ? min(barCountOverride, autoCount)
179
+ : autoCount
180
+ guard barCount > 0 else { return nil }
181
+
182
+ let verticalPadding = barWidth * 1.5
183
+ let drawableHeight = totalHeight - verticalPadding * 2
184
+ guard drawableHeight > 0 else { return nil }
185
+ let minBarHeight = barWidth
186
+ let radius = barRadius < 0 ? barWidth / 2 : barRadius
187
+ // `displayedAmps` already substitutes `placeholderAmplitude` for any
188
+ // not-yet-decoded bar in `setTargetAmplitudes`, so trailing bars stay
189
+ // at skeleton height instead of dropping to `minBarHeight`.
190
+ let usePlaceholder = displayedAmps.isEmpty
191
+
192
+ let path = UIBezierPath()
193
+ for i in 0..<barCount {
194
+ let amp: CGFloat
195
+ if usePlaceholder {
196
+ amp = Self.placeholderAmplitude
197
+ } else {
198
+ let ampIndex = i * displayedAmps.count / barCount
199
+ amp = displayedAmps[min(max(ampIndex, 0), displayedAmps.count - 1)]
200
+ }
201
+ let barHeight = max(minBarHeight, amp * drawableHeight)
202
+ let x = CGFloat(i) * step
203
+ let y = verticalPadding + (drawableHeight - barHeight) / 2.0
204
+ let rect = CGRect(x: x, y: y, width: barWidth, height: barHeight)
205
+ path.append(UIBezierPath(roundedRect: rect, cornerRadius: radius))
206
+ }
207
+ return path
208
+ }
209
+
210
+ // MARK: - Amplitude animation
211
+
212
+ /// Update the animation targets when new amplitudes arrive. Each new
213
+ /// partial smoothly grows from the currently-displayed values to the
214
+ /// new target over `amplitudeAnimationDuration` (ease-out cubic). Bars
215
+ /// expand symmetrically from the centre because the bar geometry is
216
+ /// already centre-aligned (`y = verticalPadding + (drawableHeight - h)/2`).
217
+ private func setTargetAmplitudes(_ newAmps: [CGFloat]) {
218
+ if newAmps.isEmpty {
219
+ stopAmplitudeAnimation()
220
+ displayedAmps = []
221
+ startAmps = []
222
+ targetAmps = []
223
+ invalidatePath()
224
+ return
225
+ }
226
+
227
+ // Treat zero amplitude as "not yet decoded" — keep those bars at the
228
+ // skeleton placeholder height instead of letting them snap to
229
+ // `minBarHeight` between partials.
230
+ let processed: [CGFloat] = newAmps.map { $0 > 0 ? $0 : Self.placeholderAmplitude }
231
+
232
+ if displayedAmps.count != processed.count {
233
+ // First non-empty payload (or barCount changed). Seed the
234
+ // current values with the placeholder skeleton so the animation
235
+ // grows from skeleton -> real shape.
236
+ displayedAmps = [CGFloat](repeating: Self.placeholderAmplitude, count: processed.count)
237
+ }
238
+
239
+ startAmps = displayedAmps
240
+ targetAmps = processed
241
+ amplitudeAnimationStart = CACurrentMediaTime()
242
+ startAmplitudeAnimation()
243
+ }
244
+
245
+ private func startAmplitudeAnimation() {
246
+ if amplitudeDisplayLink != nil { return }
247
+ let link = CADisplayLink(target: self, selector: #selector(handleAmplitudeTick))
248
+ link.add(to: .main, forMode: .common)
249
+ amplitudeDisplayLink = link
250
+ }
251
+
252
+ private func stopAmplitudeAnimation() {
253
+ amplitudeDisplayLink?.invalidate()
254
+ amplitudeDisplayLink = nil
255
+ }
256
+
257
+ @objc private func handleAmplitudeTick() {
258
+ let elapsed = CACurrentMediaTime() - amplitudeAnimationStart
259
+ let t = max(0, min(1, elapsed / Self.amplitudeAnimationDuration))
260
+ let eased = Self.easeOutCubic(CGFloat(t))
261
+ let count = min(displayedAmps.count, min(startAmps.count, targetAmps.count))
262
+ for i in 0..<count {
263
+ displayedAmps[i] = startAmps[i] + (targetAmps[i] - startAmps[i]) * eased
264
+ }
265
+ invalidatePath()
266
+ if t >= 1 {
267
+ stopAmplitudeAnimation()
268
+ }
269
+ }
270
+
271
+ private static func easeOutCubic(_ t: CGFloat) -> CGFloat {
272
+ let inv = 1 - t
273
+ return 1 - inv * inv * inv
274
+ }
275
+
276
+ // MARK: - Drawing
277
+
278
+ override func draw(_ rect: CGRect) {
279
+ guard let path = ensureCachedPath() else { return }
280
+ guard let ctx = UIGraphicsGetCurrentContext() else { return }
281
+
282
+ // Pass 1: fill all bars in the unplayed color.
283
+ unplayedBarColor.setFill()
284
+ path.fill()
285
+
286
+ // Pass 2: clip to [0, progressX] and fill the same path in the played color.
287
+ let clamped = max(0, min(1, progressFraction))
288
+ let progressX = clamped * bounds.width
289
+ guard progressX > 0 else { return }
290
+
291
+ ctx.saveGState()
292
+ ctx.clip(to: CGRect(x: 0, y: 0, width: progressX, height: bounds.height))
293
+ playedBarColor.setFill()
294
+ path.fill()
295
+ ctx.restoreGState()
296
+ }
297
+
298
+ // MARK: - Touch handling (immediate scrub via long-press gesture)
299
+
300
+ @objc private func handleScrubGesture(_ recognizer: UILongPressGestureRecognizer) {
301
+ let location = recognizer.location(in: self)
302
+ let fraction = clampFraction(location.x / max(1, bounds.width))
303
+ switch recognizer.state {
304
+ case .began:
305
+ progressFraction = fraction
306
+ setNeedsDisplay()
307
+ onScrubBegan?(fraction)
308
+ case .changed:
309
+ progressFraction = fraction
310
+ setNeedsDisplay()
311
+ onScrubMoved?(fraction)
312
+ case .ended:
313
+ progressFraction = fraction
314
+ setNeedsDisplay()
315
+ onScrubEnded?(fraction, false)
316
+ case .cancelled, .failed:
317
+ onScrubEnded?(progressFraction, true)
318
+ default:
319
+ break
320
+ }
321
+ }
322
+
323
+ private func clampFraction(_ value: CGFloat) -> CGFloat {
324
+ if value.isNaN { return 0 }
325
+ return max(0, min(1, value))
326
+ }
327
+ }