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.
- package/AudioWaveform.podspec +29 -0
- package/LICENSE +20 -0
- package/README.md +296 -0
- package/android/build.gradle +67 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/audiowaveform/AudioPlayerEngine.kt +353 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformEvent.kt +22 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformPackage.kt +17 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformView.kt +715 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformViewManager.kt +234 -0
- package/android/src/main/java/com/audiowaveform/PlayPauseButton.kt +106 -0
- package/android/src/main/java/com/audiowaveform/SpeedPillView.kt +70 -0
- package/android/src/main/java/com/audiowaveform/WaveformBarsView.kt +358 -0
- package/android/src/main/java/com/audiowaveform/WaveformDecoder.kt +240 -0
- package/android/src/main/res/drawable/pause_fill.xml +15 -0
- package/android/src/main/res/drawable/play_fill.xml +15 -0
- package/ios/AudioPlayerEngine.swift +281 -0
- package/ios/AudioWaveformView.h +14 -0
- package/ios/AudioWaveformView.mm +307 -0
- package/ios/AudioWaveformViewImpl.swift +835 -0
- package/ios/PlayPauseButton.swift +118 -0
- package/ios/SpeedPillView.swift +70 -0
- package/ios/WaveformBarsView.swift +327 -0
- package/ios/WaveformDecoder.swift +332 -0
- package/lib/module/AudioWaveformView.js +8 -0
- package/lib/module/AudioWaveformView.js.map +1 -0
- package/lib/module/AudioWaveformView.native.js +79 -0
- package/lib/module/AudioWaveformView.native.js.map +1 -0
- package/lib/module/AudioWaveformViewNativeComponent.ts +95 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioWaveformView.d.ts +233 -0
- package/lib/typescript/src/AudioWaveformView.d.ts.map +1 -0
- package/lib/typescript/src/AudioWaveformView.native.d.ts +335 -0
- package/lib/typescript/src/AudioWaveformView.native.d.ts.map +1 -0
- package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts +71 -0
- package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +138 -7
- package/src/AudioWaveformView.native.tsx +281 -0
- package/src/AudioWaveformView.tsx +96 -0
- package/src/AudioWaveformViewNativeComponent.ts +95 -0
- 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
|
+
}
|