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,835 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import QuartzCore
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
/// Native composite view rendered inside the Fabric `RCTViewComponentView` shim.
|
|
7
|
+
///
|
|
8
|
+
/// Layout (left -> right):
|
|
9
|
+
/// [ rounded background (optional) ]
|
|
10
|
+
/// [ play/pause button | waveform bars | stack(time, speed pill) ]
|
|
11
|
+
///
|
|
12
|
+
/// The Fabric shim (`AudioWaveformView.mm`) owns this view, sets every prop
|
|
13
|
+
/// via `@objc` setters, dispatches commands, and reads the `@objc` callback
|
|
14
|
+
/// blocks below to forward events to the C++ event emitter.
|
|
15
|
+
@objcMembers
|
|
16
|
+
public final class AudioWaveformViewImpl: UIView {
|
|
17
|
+
|
|
18
|
+
// MARK: - Subviews
|
|
19
|
+
|
|
20
|
+
private let backgroundView = UIView()
|
|
21
|
+
private let playButton = PlayPauseButton()
|
|
22
|
+
private let barsView = WaveformBarsView()
|
|
23
|
+
private let rightStack = UIView()
|
|
24
|
+
private let timeLabel = UILabel()
|
|
25
|
+
private let speedPill = SpeedPillView()
|
|
26
|
+
|
|
27
|
+
// MARK: - Audio engine + decoder
|
|
28
|
+
|
|
29
|
+
private let engine = AudioPlayerEngine()
|
|
30
|
+
private let decoder = WaveformDecoder()
|
|
31
|
+
|
|
32
|
+
// MARK: - Display link (drives waveform repaint at ~30 Hz)
|
|
33
|
+
|
|
34
|
+
private var displayLink: CADisplayLink?
|
|
35
|
+
|
|
36
|
+
// MARK: - Internal state
|
|
37
|
+
|
|
38
|
+
private var currentSourceURL: URL?
|
|
39
|
+
private var amplitudes: [CGFloat] = []
|
|
40
|
+
private var samplesProvided: Bool = false
|
|
41
|
+
|
|
42
|
+
private var internalPlaying: Bool = false
|
|
43
|
+
private var internalSpeed: Float = 1.0
|
|
44
|
+
|
|
45
|
+
private var initialPositionApplied: Bool = false
|
|
46
|
+
private var pendingScrubMs: Int? = nil
|
|
47
|
+
|
|
48
|
+
/// `true` while a touch-and-drag scrub is in progress on the bars view.
|
|
49
|
+
private var isScrubbing: Bool = false
|
|
50
|
+
/// Whether playback was running when the scrub began — restored on release.
|
|
51
|
+
private var resumeAfterScrub: Bool = false
|
|
52
|
+
|
|
53
|
+
// MARK: - Reactive props (set by the .mm Fabric shim via `@objc` setters)
|
|
54
|
+
|
|
55
|
+
public var playedBarColor: UIColor = .white {
|
|
56
|
+
didSet { barsView.playedBarColor = playedBarColor }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public var unplayedBarColor: UIColor = UIColor.white.withAlphaComponent(0.5) {
|
|
60
|
+
didSet { barsView.unplayedBarColor = unplayedBarColor }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public var barWidth: CGFloat = 3 {
|
|
64
|
+
didSet { barsView.barWidth = barWidth }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public var barGap: CGFloat = 2 {
|
|
68
|
+
didSet { barsView.barGap = barGap }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public var barRadius: CGFloat = -1 {
|
|
72
|
+
didSet { barsView.barRadius = barRadius }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public var barCountOverride: Int = 0 {
|
|
76
|
+
didSet { barsView.barCountOverride = barCountOverride }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public var containerBackgroundColor: UIColor = UIColor(red: 0.204, green: 0.471, blue: 0.965, alpha: 1) {
|
|
80
|
+
didSet { applyBackground() }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public var containerBorderRadius: CGFloat = 16 {
|
|
84
|
+
didSet { applyBackground() }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public var showBackground: Bool = true {
|
|
88
|
+
didSet { applyBackground() }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public var showPlayButton: Bool = true {
|
|
92
|
+
didSet {
|
|
93
|
+
playButton.isHidden = !showPlayButton
|
|
94
|
+
setNeedsLayout()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public var playButtonColor: UIColor = .white {
|
|
99
|
+
didSet { playButton.iconColor = playButtonColor }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public var showTime: Bool = true {
|
|
103
|
+
didSet {
|
|
104
|
+
timeLabel.isHidden = !showTime
|
|
105
|
+
setNeedsLayout()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public var timeColor: UIColor = .white {
|
|
110
|
+
didSet { timeLabel.textColor = timeColor }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Either "count-up" (default) or "count-down".
|
|
114
|
+
public var timeMode: NSString = "count-up" {
|
|
115
|
+
didSet { updateTimeLabel() }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public var showSpeedControl: Bool = true {
|
|
119
|
+
didSet {
|
|
120
|
+
speedPill.isHidden = !showSpeedControl
|
|
121
|
+
setNeedsLayout()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public var speedColor: UIColor = .white {
|
|
126
|
+
didSet { speedPill.textColor = speedColor }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public var speedBackgroundColor: UIColor = UIColor.white.withAlphaComponent(0.25) {
|
|
130
|
+
didSet { speedPill.pillColor = speedBackgroundColor }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public var speeds: [NSNumber] = [0.5, 1.0, 1.5, 2.0]
|
|
134
|
+
|
|
135
|
+
public var defaultSpeed: Float = 1.0 {
|
|
136
|
+
didSet {
|
|
137
|
+
// Only respect `defaultSpeed` until the user (or controlled prop)
|
|
138
|
+
// has actively set a speed.
|
|
139
|
+
if !defaultSpeedApplied {
|
|
140
|
+
applyEffectiveSpeed(defaultSpeed)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
private var defaultSpeedApplied: Bool = false
|
|
145
|
+
|
|
146
|
+
public var autoPlay: Bool = false
|
|
147
|
+
public var initialPositionMs: Int = 0
|
|
148
|
+
public var loop: Bool = false {
|
|
149
|
+
didSet { engine.loop = loop }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Whether playback should continue when the host app is backgrounded.
|
|
153
|
+
/// Default `false` — we pause on `UIApplication.didEnterBackground`.
|
|
154
|
+
public var playInBackground: Bool = false {
|
|
155
|
+
didSet {
|
|
156
|
+
if playInBackground {
|
|
157
|
+
engine.setBackgroundPlaybackEnabled(true)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// While the app is backgrounded, skip the bars / time-label refreshes
|
|
163
|
+
/// that would otherwise piggy-back on every 30 Hz progress tick. The JS
|
|
164
|
+
/// `onTimeUpdate` event keeps firing regardless. Default `true`.
|
|
165
|
+
public var pauseUiUpdatesInBackground: Bool = true
|
|
166
|
+
|
|
167
|
+
/// Controlled "playing" prop: `-1` = uncontrolled, `0` = paused, `1` = playing.
|
|
168
|
+
public var controlledPlaying: Int = -1 {
|
|
169
|
+
didSet { applyControlledState() }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Controlled "speed" prop: `< 0` = uncontrolled, otherwise the rate.
|
|
173
|
+
public var controlledSpeed: Float = -1 {
|
|
174
|
+
didSet { applyControlledState() }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// MARK: - Source
|
|
178
|
+
|
|
179
|
+
/// Set by the .mm shim from `newViewProps.source.uri`. Empty string clears.
|
|
180
|
+
public var sourceURI: NSString = "" {
|
|
181
|
+
didSet {
|
|
182
|
+
guard sourceURI != oldValue else { return }
|
|
183
|
+
applySource()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// MARK: - Provided samples
|
|
188
|
+
|
|
189
|
+
/// When non-nil, `samples` are used directly and native decode is skipped.
|
|
190
|
+
public var providedSamples: [NSNumber]? = nil {
|
|
191
|
+
didSet {
|
|
192
|
+
applyProvidedSamples()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// MARK: - Event callbacks (set by .mm; forwarded to C++ event emitter)
|
|
197
|
+
|
|
198
|
+
public var onLoad: ((Int) -> Void)?
|
|
199
|
+
public var onLoadError: ((NSString) -> Void)?
|
|
200
|
+
public var onPlayerStateChange: ((NSString, Bool, Float, NSString) -> Void)?
|
|
201
|
+
public var onTimeUpdate: ((Int, Int) -> Void)?
|
|
202
|
+
public var onSeek: ((Int) -> Void)?
|
|
203
|
+
public var onEnd: (() -> Void)?
|
|
204
|
+
|
|
205
|
+
// MARK: - Init
|
|
206
|
+
|
|
207
|
+
private var didEnterBackgroundObserver: NSObjectProtocol?
|
|
208
|
+
private var didBecomeActiveObserver: NSObjectProtocol?
|
|
209
|
+
private var isBackgrounded: Bool = false
|
|
210
|
+
|
|
211
|
+
public override init(frame: CGRect) {
|
|
212
|
+
super.init(frame: frame)
|
|
213
|
+
commonInit()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public required init?(coder: NSCoder) {
|
|
217
|
+
super.init(coder: coder)
|
|
218
|
+
commonInit()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
deinit {
|
|
222
|
+
if let token = didEnterBackgroundObserver {
|
|
223
|
+
NotificationCenter.default.removeObserver(token)
|
|
224
|
+
}
|
|
225
|
+
if let token = didBecomeActiveObserver {
|
|
226
|
+
NotificationCenter.default.removeObserver(token)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private func commonInit() {
|
|
231
|
+
backgroundColor = .clear
|
|
232
|
+
clipsToBounds = false
|
|
233
|
+
|
|
234
|
+
backgroundView.layer.cornerRadius = containerBorderRadius
|
|
235
|
+
backgroundView.backgroundColor = containerBackgroundColor
|
|
236
|
+
addSubview(backgroundView)
|
|
237
|
+
|
|
238
|
+
playButton.addTarget(self, action: #selector(handlePlayButtonTap), for: .touchUpInside)
|
|
239
|
+
playButton.iconColor = playButtonColor
|
|
240
|
+
addSubview(playButton)
|
|
241
|
+
|
|
242
|
+
barsView.playedBarColor = playedBarColor
|
|
243
|
+
barsView.unplayedBarColor = unplayedBarColor
|
|
244
|
+
barsView.barWidth = barWidth
|
|
245
|
+
barsView.barGap = barGap
|
|
246
|
+
barsView.barRadius = barRadius
|
|
247
|
+
barsView.onScrubBegan = { [weak self] fraction in self?.handleScrubBegan(fraction: fraction) }
|
|
248
|
+
barsView.onScrubMoved = { [weak self] fraction in self?.handleScrubMoved(fraction: fraction) }
|
|
249
|
+
barsView.onScrubEnded = { [weak self] fraction, cancelled in
|
|
250
|
+
self?.handleScrubEnded(fraction: fraction, cancelled: cancelled)
|
|
251
|
+
}
|
|
252
|
+
addSubview(barsView)
|
|
253
|
+
|
|
254
|
+
rightStack.backgroundColor = .clear
|
|
255
|
+
addSubview(rightStack)
|
|
256
|
+
|
|
257
|
+
timeLabel.text = "0:00"
|
|
258
|
+
timeLabel.textColor = timeColor
|
|
259
|
+
timeLabel.font = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
|
260
|
+
timeLabel.textAlignment = .right
|
|
261
|
+
rightStack.addSubview(timeLabel)
|
|
262
|
+
|
|
263
|
+
speedPill.pillColor = speedBackgroundColor
|
|
264
|
+
speedPill.textColor = speedColor
|
|
265
|
+
speedPill.setSpeed(defaultSpeed)
|
|
266
|
+
speedPill.onTap = { [weak self] in self?.handleSpeedPillTap() }
|
|
267
|
+
rightStack.addSubview(speedPill)
|
|
268
|
+
|
|
269
|
+
wireEngineCallbacks()
|
|
270
|
+
observeAppBackground()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func observeAppBackground() {
|
|
274
|
+
didEnterBackgroundObserver = NotificationCenter.default.addObserver(
|
|
275
|
+
forName: UIApplication.didEnterBackgroundNotification,
|
|
276
|
+
object: nil,
|
|
277
|
+
queue: .main
|
|
278
|
+
) { [weak self] _ in
|
|
279
|
+
self?.handleAppDidEnterBackground()
|
|
280
|
+
}
|
|
281
|
+
didBecomeActiveObserver = NotificationCenter.default.addObserver(
|
|
282
|
+
forName: UIApplication.didBecomeActiveNotification,
|
|
283
|
+
object: nil,
|
|
284
|
+
queue: .main
|
|
285
|
+
) { [weak self] _ in
|
|
286
|
+
self?.handleAppDidBecomeActive()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private func handleAppDidEnterBackground() {
|
|
291
|
+
isBackgrounded = true
|
|
292
|
+
guard !playInBackground else { return }
|
|
293
|
+
guard engine.isPlaying else { return }
|
|
294
|
+
engine.pause()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private func handleAppDidBecomeActive() {
|
|
298
|
+
isBackgrounded = false
|
|
299
|
+
// Snap the UI to the engine's current state in case we skipped
|
|
300
|
+
// tick updates while backgrounded.
|
|
301
|
+
let dur = engine.durationMs
|
|
302
|
+
let cur = engine.currentMs
|
|
303
|
+
if dur > 0 {
|
|
304
|
+
barsView.progressFraction = CGFloat(cur) / CGFloat(dur)
|
|
305
|
+
}
|
|
306
|
+
updateTimeLabel(currentMs: cur, durationMs: dur)
|
|
307
|
+
playButton.isPlaying = engine.isPlaying
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// MARK: - Layout
|
|
311
|
+
|
|
312
|
+
public override func layoutSubviews() {
|
|
313
|
+
super.layoutSubviews()
|
|
314
|
+
|
|
315
|
+
backgroundView.frame = bounds
|
|
316
|
+
|
|
317
|
+
let inset: CGFloat = 12
|
|
318
|
+
let height = bounds.height
|
|
319
|
+
var x = inset
|
|
320
|
+
let availableWidth = max(0, bounds.width - inset * 2)
|
|
321
|
+
var remaining = availableWidth
|
|
322
|
+
|
|
323
|
+
let buttonSize = min(height * 0.6, 36)
|
|
324
|
+
if showPlayButton {
|
|
325
|
+
playButton.frame = CGRect(
|
|
326
|
+
x: x,
|
|
327
|
+
y: (height - buttonSize) / 2,
|
|
328
|
+
width: buttonSize,
|
|
329
|
+
height: buttonSize
|
|
330
|
+
)
|
|
331
|
+
x += buttonSize + 8
|
|
332
|
+
remaining -= (buttonSize + 8)
|
|
333
|
+
} else {
|
|
334
|
+
playButton.frame = .zero
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Right side: time + speed
|
|
338
|
+
let rightWidth: CGFloat = (showTime || showSpeedControl) ? 56 : 0
|
|
339
|
+
if rightWidth > 0 {
|
|
340
|
+
rightStack.frame = CGRect(
|
|
341
|
+
x: bounds.width - inset - rightWidth,
|
|
342
|
+
y: 0,
|
|
343
|
+
width: rightWidth,
|
|
344
|
+
height: height
|
|
345
|
+
)
|
|
346
|
+
remaining -= (rightWidth + 8)
|
|
347
|
+
|
|
348
|
+
// Stack within the right column.
|
|
349
|
+
let timeHeight: CGFloat = 18
|
|
350
|
+
let pillHeight: CGFloat = 22
|
|
351
|
+
let pillWidth: CGFloat = 44
|
|
352
|
+
let stackContentHeight = (showTime ? timeHeight : 0)
|
|
353
|
+
+ (showTime && showSpeedControl ? 4 : 0)
|
|
354
|
+
+ (showSpeedControl ? pillHeight : 0)
|
|
355
|
+
var sy = (height - stackContentHeight) / 2
|
|
356
|
+
if showTime {
|
|
357
|
+
timeLabel.frame = CGRect(x: 0, y: sy, width: rightWidth, height: timeHeight)
|
|
358
|
+
sy += timeHeight + (showSpeedControl ? 4 : 0)
|
|
359
|
+
} else {
|
|
360
|
+
timeLabel.frame = .zero
|
|
361
|
+
}
|
|
362
|
+
if showSpeedControl {
|
|
363
|
+
speedPill.frame = CGRect(
|
|
364
|
+
x: rightWidth - pillWidth,
|
|
365
|
+
y: sy,
|
|
366
|
+
width: pillWidth,
|
|
367
|
+
height: pillHeight
|
|
368
|
+
)
|
|
369
|
+
} else {
|
|
370
|
+
speedPill.frame = .zero
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
rightStack.frame = .zero
|
|
374
|
+
timeLabel.frame = .zero
|
|
375
|
+
speedPill.frame = .zero
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Waveform fills the full height between left and right; the bars
|
|
379
|
+
// view itself adds vertical breathing room via `barWidth * 1.5` padding.
|
|
380
|
+
let barsX = x
|
|
381
|
+
let barsWidth = max(0, bounds.width - inset - rightWidth - (rightWidth > 0 ? 8 : 0) - barsX)
|
|
382
|
+
barsView.frame = CGRect(
|
|
383
|
+
x: barsX,
|
|
384
|
+
y: 0,
|
|
385
|
+
width: barsWidth,
|
|
386
|
+
height: height
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// MARK: - Source / samples
|
|
391
|
+
|
|
392
|
+
private func applySource() {
|
|
393
|
+
let s = sourceURI as String
|
|
394
|
+
if s.isEmpty {
|
|
395
|
+
currentSourceURL = nil
|
|
396
|
+
amplitudes = []
|
|
397
|
+
barsView.amplitudes = []
|
|
398
|
+
decoder.cancel()
|
|
399
|
+
engine.reset()
|
|
400
|
+
stopDisplayLink()
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
guard let url = URL(string: s) else {
|
|
404
|
+
onLoadError?(s as NSString)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
currentSourceURL = url
|
|
408
|
+
initialPositionApplied = false
|
|
409
|
+
|
|
410
|
+
// Reset stale waveform so the placeholder bars show during loading
|
|
411
|
+
// rather than the previous source's amplitudes.
|
|
412
|
+
amplitudes = []
|
|
413
|
+
barsView.amplitudes = []
|
|
414
|
+
decoder.cancel()
|
|
415
|
+
|
|
416
|
+
engine.setSource(url: url)
|
|
417
|
+
emitPlayerState()
|
|
418
|
+
|
|
419
|
+
// Note: waveform decode is intentionally deferred to `engine.onLoad`.
|
|
420
|
+
// For remote URLs the waveform decoder runs its own URLSession
|
|
421
|
+
// download in parallel with AVPlayer's streaming buffer; if both
|
|
422
|
+
// run from `applySource` they fight for bandwidth on the same
|
|
423
|
+
// HTTP connection and the engine takes much longer to flip to
|
|
424
|
+
// `.ready`. By holding off until `onLoad`, AVPlayer gets undivided
|
|
425
|
+
// bandwidth for its initial buffer and the spinner clears as soon
|
|
426
|
+
// as `.readyToPlay` fires (typically 1-2 seconds even on slow
|
|
427
|
+
// connections), and the waveform then fills in progressively.
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private func applyProvidedSamples() {
|
|
431
|
+
guard let provided = providedSamples else {
|
|
432
|
+
samplesProvided = false
|
|
433
|
+
decodeAmplitudesIfPossible()
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if provided.isEmpty {
|
|
437
|
+
samplesProvided = false
|
|
438
|
+
decodeAmplitudesIfPossible()
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
samplesProvided = true
|
|
442
|
+
decoder.cancel()
|
|
443
|
+
let parsed = provided.map { CGFloat(truncating: $0) }
|
|
444
|
+
amplitudes = normalise(parsed)
|
|
445
|
+
barsView.amplitudes = amplitudes
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private func decodeAmplitudesIfPossible() {
|
|
449
|
+
guard !samplesProvided else { return }
|
|
450
|
+
guard let url = currentSourceURL else { return }
|
|
451
|
+
// Without a meaningful width yet we can still kick off decode using a
|
|
452
|
+
// sensible default bar count; the bars view downsamples to its own
|
|
453
|
+
// bar count at draw time anyway.
|
|
454
|
+
let provisionalCount = Math.barCountForWidth(
|
|
455
|
+
width: barsView.bounds.width,
|
|
456
|
+
barWidth: barWidth,
|
|
457
|
+
barGap: barGap,
|
|
458
|
+
fallback: 80
|
|
459
|
+
)
|
|
460
|
+
decoder.decode(
|
|
461
|
+
url: url,
|
|
462
|
+
barCount: provisionalCount,
|
|
463
|
+
progress: { [weak self] amps in
|
|
464
|
+
guard let self = self else { return }
|
|
465
|
+
// Show partial waveform as it decodes — same UX as Android.
|
|
466
|
+
self.amplitudes = amps
|
|
467
|
+
self.barsView.amplitudes = amps
|
|
468
|
+
},
|
|
469
|
+
completion: { [weak self] amps in
|
|
470
|
+
guard let self = self else { return }
|
|
471
|
+
self.amplitudes = amps
|
|
472
|
+
self.barsView.amplitudes = amps
|
|
473
|
+
},
|
|
474
|
+
failure: { [weak self] message in
|
|
475
|
+
self?.onLoadError?(message as NSString)
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private func normalise(_ values: [CGFloat]) -> [CGFloat] {
|
|
481
|
+
let maxValue = values.max() ?? 0
|
|
482
|
+
if maxValue <= 0 { return values.map { _ in 0 } }
|
|
483
|
+
if maxValue <= 1 { return values.map { max(0, min(1, $0)) } }
|
|
484
|
+
// Renormalise if the consumer passed something other than 0..1.
|
|
485
|
+
return values.map { max(0, min(1, $0 / maxValue)) }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// MARK: - Background / appearance
|
|
489
|
+
|
|
490
|
+
private func applyBackground() {
|
|
491
|
+
if showBackground {
|
|
492
|
+
backgroundView.isHidden = false
|
|
493
|
+
backgroundView.backgroundColor = containerBackgroundColor
|
|
494
|
+
backgroundView.layer.cornerRadius = containerBorderRadius
|
|
495
|
+
backgroundView.layer.masksToBounds = true
|
|
496
|
+
} else {
|
|
497
|
+
backgroundView.isHidden = true
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// MARK: - Engine / state plumbing
|
|
502
|
+
|
|
503
|
+
private func wireEngineCallbacks() {
|
|
504
|
+
engine.onLoad = { [weak self] durationMs in
|
|
505
|
+
guard let self = self else { return }
|
|
506
|
+
self.onLoad?(durationMs)
|
|
507
|
+
// Apply any pending initialPositionMs once the source is ready.
|
|
508
|
+
if !self.initialPositionApplied, self.initialPositionMs > 0 {
|
|
509
|
+
self.engine.seek(toMs: self.initialPositionMs)
|
|
510
|
+
self.initialPositionApplied = true
|
|
511
|
+
} else {
|
|
512
|
+
self.initialPositionApplied = true
|
|
513
|
+
}
|
|
514
|
+
self.emitPlayerState()
|
|
515
|
+
// Apply autoplay / controlled state now that we're ready.
|
|
516
|
+
if self.controlledPlaying == 1 {
|
|
517
|
+
self.engine.play()
|
|
518
|
+
self.startDisplayLink()
|
|
519
|
+
} else if self.controlledPlaying == -1, self.autoPlay {
|
|
520
|
+
self.internalPlaying = true
|
|
521
|
+
self.engine.play()
|
|
522
|
+
self.startDisplayLink()
|
|
523
|
+
}
|
|
524
|
+
self.emitPlayerState()
|
|
525
|
+
// Kick off waveform decode now that AVPlayer has its initial
|
|
526
|
+
// buffer — see `applySource` for rationale. Skipped if the
|
|
527
|
+
// caller already supplied samples.
|
|
528
|
+
if !self.samplesProvided, self.amplitudes.isEmpty {
|
|
529
|
+
self.decodeAmplitudesIfPossible()
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
engine.onLoadError = { [weak self] message in
|
|
533
|
+
self?.onLoadError?(message as NSString)
|
|
534
|
+
self?.emitPlayerState(error: message)
|
|
535
|
+
}
|
|
536
|
+
engine.onStateChange = { [weak self] in
|
|
537
|
+
self?.handleEngineStateChange()
|
|
538
|
+
}
|
|
539
|
+
engine.onTimeUpdate = { [weak self] currentMs, durationMs in
|
|
540
|
+
guard let self = self else { return }
|
|
541
|
+
if self.isScrubbing { return }
|
|
542
|
+
// JS event always fires (callers may want it for now-playing UI).
|
|
543
|
+
self.onTimeUpdate?(currentMs, durationMs)
|
|
544
|
+
// Skip the cheap-but-pointless UI work while backgrounded.
|
|
545
|
+
if self.isBackgrounded && self.pauseUiUpdatesInBackground { return }
|
|
546
|
+
self.barsView.progressFraction = durationMs > 0
|
|
547
|
+
? CGFloat(currentMs) / CGFloat(durationMs)
|
|
548
|
+
: 0
|
|
549
|
+
self.updateTimeLabel(currentMs: currentMs, durationMs: durationMs)
|
|
550
|
+
}
|
|
551
|
+
engine.onEnded = { [weak self] in
|
|
552
|
+
guard let self = self else { return }
|
|
553
|
+
self.internalPlaying = false
|
|
554
|
+
self.onEnd?()
|
|
555
|
+
self.emitPlayerState()
|
|
556
|
+
self.stopDisplayLink()
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private func handleEngineStateChange() {
|
|
561
|
+
// Keep the play/pause icon in sync on every transition. Without this
|
|
562
|
+
// the icon only refreshes from the display-link tick, which means
|
|
563
|
+
// pausing (imperative or controlled) leaves a stale "playing" icon
|
|
564
|
+
// because the display link stops the moment we pause.
|
|
565
|
+
//
|
|
566
|
+
// Order matters: update `isPlaying` *before* `isLoading`. While the
|
|
567
|
+
// spinner is still showing (isLoading=true) the icon swap is a snap
|
|
568
|
+
// (no crossfade); then we drop the spinner and the imageView is
|
|
569
|
+
// already pointing at the right icon. If we did it in the opposite
|
|
570
|
+
// order the imageView would briefly reveal the previous icon and
|
|
571
|
+
// we'd see a 0.12s crossfade flash on every load completion that
|
|
572
|
+
// had a pending tap.
|
|
573
|
+
playButton.isPlaying = engine.isPlaying
|
|
574
|
+
playButton.isLoading = (engine.state == .loading)
|
|
575
|
+
if engine.isPlaying {
|
|
576
|
+
startDisplayLink()
|
|
577
|
+
} else {
|
|
578
|
+
stopDisplayLink()
|
|
579
|
+
}
|
|
580
|
+
emitPlayerState()
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private func emitPlayerState(error: String? = nil) {
|
|
584
|
+
let stateString: String
|
|
585
|
+
switch engine.state {
|
|
586
|
+
case .idle: stateString = "idle"
|
|
587
|
+
case .loading: stateString = "loading"
|
|
588
|
+
case .ready: stateString = "ready"
|
|
589
|
+
case .ended: stateString = "ended"
|
|
590
|
+
case .error: stateString = "error"
|
|
591
|
+
}
|
|
592
|
+
let speed = effectiveSpeed()
|
|
593
|
+
onPlayerStateChange?(
|
|
594
|
+
stateString as NSString,
|
|
595
|
+
engine.isPlaying,
|
|
596
|
+
speed,
|
|
597
|
+
(error ?? "") as NSString
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// MARK: - Time label
|
|
602
|
+
|
|
603
|
+
private func updateTimeLabel(currentMs: Int? = nil, durationMs: Int? = nil) {
|
|
604
|
+
let cur = currentMs ?? engine.currentMs
|
|
605
|
+
let dur = durationMs ?? engine.durationMs
|
|
606
|
+
let mode = (timeMode as String).lowercased()
|
|
607
|
+
let display: Int
|
|
608
|
+
if mode == "count-down" {
|
|
609
|
+
display = max(0, dur - cur)
|
|
610
|
+
} else {
|
|
611
|
+
display = cur
|
|
612
|
+
}
|
|
613
|
+
let totalSeconds = display / 1000
|
|
614
|
+
let minutes = totalSeconds / 60
|
|
615
|
+
let seconds = totalSeconds % 60
|
|
616
|
+
timeLabel.text = String(format: "%d:%02d", minutes, seconds)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// MARK: - Speed handling
|
|
620
|
+
|
|
621
|
+
private func effectiveSpeed() -> Float {
|
|
622
|
+
if controlledSpeed >= 0 { return controlledSpeed }
|
|
623
|
+
return internalSpeed
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/// Picks the next speed in the configured `speeds` list. Used when the
|
|
627
|
+
/// user taps the pill (only changes internal state if uncontrolled).
|
|
628
|
+
private func nextSpeed(after current: Float) -> Float {
|
|
629
|
+
guard !speeds.isEmpty else { return 1.0 }
|
|
630
|
+
let values = speeds.map { $0.floatValue }
|
|
631
|
+
// Find the smallest one strictly greater than the current speed; wrap
|
|
632
|
+
// around to the smallest if we're already at the largest.
|
|
633
|
+
if let next = values.first(where: { $0 > current + 0.001 }) {
|
|
634
|
+
return next
|
|
635
|
+
}
|
|
636
|
+
return values.first ?? 1.0
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private func applyEffectiveSpeed(_ rate: Float) {
|
|
640
|
+
if controlledSpeed < 0 {
|
|
641
|
+
internalSpeed = rate
|
|
642
|
+
defaultSpeedApplied = true
|
|
643
|
+
}
|
|
644
|
+
engine.setRate(rate)
|
|
645
|
+
speedPill.setSpeed(rate)
|
|
646
|
+
emitPlayerState()
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private func applyControlledState() {
|
|
650
|
+
// Speed
|
|
651
|
+
if controlledSpeed >= 0 {
|
|
652
|
+
engine.setRate(controlledSpeed)
|
|
653
|
+
speedPill.setSpeed(controlledSpeed)
|
|
654
|
+
}
|
|
655
|
+
// Playing
|
|
656
|
+
switch controlledPlaying {
|
|
657
|
+
case 0:
|
|
658
|
+
if engine.isPlaying { engine.pause() }
|
|
659
|
+
case 1:
|
|
660
|
+
// The engine's `play()` understands `.loading` and queues a
|
|
661
|
+
// pending start, so we forward the intent regardless of state
|
|
662
|
+
// and let the engine resume playback the moment buffering
|
|
663
|
+
// finishes (or no-op if we're in `.idle` / `.error`).
|
|
664
|
+
engine.play()
|
|
665
|
+
if engine.isPlaying { startDisplayLink() }
|
|
666
|
+
default:
|
|
667
|
+
break
|
|
668
|
+
}
|
|
669
|
+
emitPlayerState()
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// MARK: - Action handlers
|
|
673
|
+
|
|
674
|
+
@objc private func handlePlayButtonTap() {
|
|
675
|
+
if controlledPlaying != -1 {
|
|
676
|
+
// Controlled — fire event with the requested *new* state, but don't toggle.
|
|
677
|
+
let newPlaying = !engine.isPlaying
|
|
678
|
+
let speed = effectiveSpeed()
|
|
679
|
+
playButton.isPlaying = engine.isPlaying // restore visual state
|
|
680
|
+
onPlayerStateChange?(
|
|
681
|
+
stateString() as NSString,
|
|
682
|
+
newPlaying,
|
|
683
|
+
speed,
|
|
684
|
+
""
|
|
685
|
+
)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
engine.toggle()
|
|
689
|
+
internalPlaying = engine.isPlaying
|
|
690
|
+
playButton.isPlaying = engine.isPlaying
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private func handleSpeedPillTap() {
|
|
694
|
+
let current = effectiveSpeed()
|
|
695
|
+
let next = nextSpeed(after: current)
|
|
696
|
+
if controlledSpeed >= 0 {
|
|
697
|
+
// Controlled — fire event with the requested *new* speed.
|
|
698
|
+
onPlayerStateChange?(
|
|
699
|
+
stateString() as NSString,
|
|
700
|
+
engine.isPlaying,
|
|
701
|
+
next,
|
|
702
|
+
""
|
|
703
|
+
)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
applyEffectiveSpeed(next)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private func stateString() -> String {
|
|
710
|
+
switch engine.state {
|
|
711
|
+
case .idle: return "idle"
|
|
712
|
+
case .loading: return "loading"
|
|
713
|
+
case .ready: return "ready"
|
|
714
|
+
case .ended: return "ended"
|
|
715
|
+
case .error: return "error"
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// MARK: - Scrub handlers (forwarded by WaveformBarsView)
|
|
720
|
+
|
|
721
|
+
private func handleScrubBegan(fraction: CGFloat) {
|
|
722
|
+
isScrubbing = true
|
|
723
|
+
resumeAfterScrub = engine.isPlaying
|
|
724
|
+
if engine.isPlaying { engine.pause() }
|
|
725
|
+
let positionMs = positionFromFraction(fraction)
|
|
726
|
+
pendingScrubMs = positionMs
|
|
727
|
+
engine.seek(toMs: positionMs)
|
|
728
|
+
updateTimeLabel(currentMs: positionMs, durationMs: engine.durationMs)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private func handleScrubMoved(fraction: CGFloat) {
|
|
732
|
+
let positionMs = positionFromFraction(fraction)
|
|
733
|
+
pendingScrubMs = positionMs
|
|
734
|
+
engine.seek(toMs: positionMs)
|
|
735
|
+
updateTimeLabel(currentMs: positionMs, durationMs: engine.durationMs)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private func handleScrubEnded(fraction: CGFloat, cancelled: Bool) {
|
|
739
|
+
isScrubbing = false
|
|
740
|
+
let positionMs = positionFromFraction(fraction)
|
|
741
|
+
pendingScrubMs = nil
|
|
742
|
+
engine.seek(toMs: positionMs)
|
|
743
|
+
updateTimeLabel(currentMs: positionMs, durationMs: engine.durationMs)
|
|
744
|
+
onSeek?(positionMs)
|
|
745
|
+
if !cancelled, resumeAfterScrub, controlledPlaying != 0 {
|
|
746
|
+
engine.play()
|
|
747
|
+
startDisplayLink()
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private func positionFromFraction(_ fraction: CGFloat) -> Int {
|
|
752
|
+
let dur = engine.durationMs
|
|
753
|
+
guard dur > 0 else { return 0 }
|
|
754
|
+
let clamped = max(0, min(1, fraction))
|
|
755
|
+
return Int(clamped * CGFloat(dur))
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// MARK: - Imperative commands (called from .mm)
|
|
759
|
+
|
|
760
|
+
public func play() {
|
|
761
|
+
if controlledPlaying != -1 { return }
|
|
762
|
+
engine.play()
|
|
763
|
+
internalPlaying = true
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
public func pause() {
|
|
767
|
+
if controlledPlaying != -1 { return }
|
|
768
|
+
engine.pause()
|
|
769
|
+
internalPlaying = false
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
public func toggle() {
|
|
773
|
+
if controlledPlaying != -1 { return }
|
|
774
|
+
engine.toggle()
|
|
775
|
+
internalPlaying = engine.isPlaying
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
public func seek(toMs ms: Int) {
|
|
779
|
+
engine.seek(toMs: ms)
|
|
780
|
+
updateTimeLabel(currentMs: ms, durationMs: engine.durationMs)
|
|
781
|
+
onSeek?(ms)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
public func setSpeedValue(_ value: Float) {
|
|
785
|
+
if controlledSpeed >= 0 { return }
|
|
786
|
+
applyEffectiveSpeed(value)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// MARK: - Display link (~30 Hz repaint while playing or scrubbing)
|
|
790
|
+
|
|
791
|
+
private func startDisplayLink() {
|
|
792
|
+
if displayLink != nil { return }
|
|
793
|
+
let link = CADisplayLink(target: self, selector: #selector(handleDisplayTick))
|
|
794
|
+
if #available(iOS 15.0, *) {
|
|
795
|
+
link.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30)
|
|
796
|
+
} else {
|
|
797
|
+
link.preferredFramesPerSecond = 30
|
|
798
|
+
}
|
|
799
|
+
link.add(to: .main, forMode: .common)
|
|
800
|
+
displayLink = link
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private func stopDisplayLink() {
|
|
804
|
+
displayLink?.invalidate()
|
|
805
|
+
displayLink = nil
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
@objc private func handleDisplayTick() {
|
|
809
|
+
let dur = engine.durationMs
|
|
810
|
+
guard dur > 0 else { return }
|
|
811
|
+
let cur = engine.currentMs
|
|
812
|
+
if !isScrubbing {
|
|
813
|
+
barsView.progressFraction = CGFloat(cur) / CGFloat(dur)
|
|
814
|
+
}
|
|
815
|
+
playButton.isPlaying = engine.isPlaying
|
|
816
|
+
updateTimeLabel(currentMs: cur, durationMs: dur)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// MARK: - Helpers
|
|
821
|
+
|
|
822
|
+
private enum Math {
|
|
823
|
+
/// Compute a sensible bar count for a given view width.
|
|
824
|
+
static func barCountForWidth(
|
|
825
|
+
width: CGFloat,
|
|
826
|
+
barWidth: CGFloat,
|
|
827
|
+
barGap: CGFloat,
|
|
828
|
+
fallback: Int
|
|
829
|
+
) -> Int {
|
|
830
|
+
let step = barWidth + barGap
|
|
831
|
+
guard step > 0 else { return fallback }
|
|
832
|
+
if width <= 0 { return fallback }
|
|
833
|
+
return max(8, Int(floor(width / step)))
|
|
834
|
+
}
|
|
835
|
+
}
|