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,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
+ }