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,715 @@
1
+ package com.audiowaveform
2
+
3
+ import android.content.Context
4
+ import android.graphics.Color
5
+ import android.graphics.drawable.GradientDrawable
6
+ import android.util.AttributeSet
7
+ import android.view.Gravity
8
+ import android.view.View
9
+ import android.view.ViewGroup
10
+ import android.widget.FrameLayout
11
+ import android.widget.LinearLayout
12
+ import android.widget.TextView
13
+ import com.facebook.react.bridge.LifecycleEventListener
14
+ import com.facebook.react.bridge.ReactContext
15
+ import com.facebook.react.bridge.ReadableArray
16
+
17
+ /**
18
+ * Composite native view rendered by the Fabric `AudioWaveformViewManager`.
19
+ *
20
+ * Layout (left -> right):
21
+ * [ rounded background (optional) ]
22
+ * [ play/pause button | waveform bars | stack(time, speed pill) ]
23
+ *
24
+ * The view manager owns this and routes Fabric prop updates / commands to
25
+ * the public setters/methods below, then dispatches events through the
26
+ * `onLoad/onLoadError/onPlayerStateChange/onTimeUpdate/onSeek/onEnd`
27
+ * callbacks (set up in `AudioWaveformViewManager`).
28
+ */
29
+ class AudioWaveformView @JvmOverloads constructor(
30
+ context: Context,
31
+ attrs: AttributeSet? = null,
32
+ defStyleAttr: Int = 0
33
+ ) : FrameLayout(context, attrs, defStyleAttr), LifecycleEventListener {
34
+
35
+ private val density = resources.displayMetrics.density
36
+
37
+ // region Subviews ------------------------------------------------------------
38
+
39
+ private val backgroundDrawable = GradientDrawable().apply {
40
+ shape = GradientDrawable.RECTANGLE
41
+ setColor(Color.parseColor("#3478F6"))
42
+ cornerRadius = 16f * density
43
+ }
44
+
45
+ private val rowContainer = LinearLayout(context).apply {
46
+ orientation = LinearLayout.HORIZONTAL
47
+ gravity = Gravity.CENTER_VERTICAL
48
+ val hPadding = (12f * density).toInt()
49
+ setPadding(hPadding, 0, hPadding, 0)
50
+ }
51
+
52
+ private val playButton = PlayPauseButton(context).apply {
53
+ iconColor = Color.WHITE
54
+ }
55
+
56
+ private val barsView = WaveformBarsView(context).apply {
57
+ playedBarColor = Color.WHITE
58
+ unplayedBarColor = Color.argb(128, 255, 255, 255)
59
+ barWidthPx = 3f * density
60
+ barGapPx = 2f * density
61
+ }
62
+
63
+ private val rightStack = LinearLayout(context).apply {
64
+ orientation = LinearLayout.VERTICAL
65
+ gravity = Gravity.CENTER_HORIZONTAL
66
+ }
67
+
68
+ private val timeLabel = TextView(context).apply {
69
+ gravity = Gravity.END
70
+ setTextColor(Color.WHITE)
71
+ text = "0:00"
72
+ textSize = 13f
73
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
74
+ }
75
+
76
+ private val speedPill = SpeedPillView(context).apply {
77
+ setSpeed(1.0f)
78
+ }
79
+
80
+ // endregion
81
+
82
+ // region Audio engine + decoder ---------------------------------------------
83
+
84
+ private val engine = AudioPlayerEngine()
85
+ private val decoder = WaveformDecoder()
86
+
87
+ // endregion
88
+
89
+ // region Internal state ------------------------------------------------------
90
+
91
+ private var currentSourceUri: String? = null
92
+ private var samplesProvided: Boolean = false
93
+
94
+ private var internalSpeed: Float = 1.0f
95
+ private var defaultSpeedApplied: Boolean = false
96
+
97
+ private var initialPositionApplied: Boolean = false
98
+
99
+ private var isScrubbing: Boolean = false
100
+ private var resumeAfterScrub: Boolean = false
101
+
102
+ private var isBackgrounded: Boolean = false
103
+
104
+ // endregion
105
+
106
+ // region Reactive props (set by the view manager) ---------------------------
107
+
108
+ var playedBarColor: Int = Color.WHITE
109
+ set(value) {
110
+ field = value
111
+ barsView.playedBarColor = value
112
+ }
113
+
114
+ var unplayedBarColor: Int = Color.argb(128, 255, 255, 255)
115
+ set(value) {
116
+ field = value
117
+ barsView.unplayedBarColor = value
118
+ }
119
+
120
+ var barWidthDp: Float = 3f
121
+ set(value) {
122
+ field = value
123
+ barsView.barWidthPx = value * density
124
+ }
125
+
126
+ var barGapDp: Float = 2f
127
+ set(value) {
128
+ field = value
129
+ barsView.barGapPx = value * density
130
+ }
131
+
132
+ var barRadiusDp: Float = -1f
133
+ set(value) {
134
+ field = value
135
+ barsView.barRadiusPx = if (value < 0f) -1f else value * density
136
+ }
137
+
138
+ var barCountOverride: Int = 0
139
+ set(value) {
140
+ field = value
141
+ barsView.barCountOverride = value
142
+ }
143
+
144
+ var containerBackgroundColor: Int = Color.parseColor("#3478F6")
145
+ set(value) {
146
+ field = value
147
+ backgroundDrawable.setColor(value)
148
+ applyBackground()
149
+ }
150
+
151
+ var containerBorderRadiusDp: Float = 16f
152
+ set(value) {
153
+ field = value
154
+ backgroundDrawable.cornerRadius = value * density
155
+ applyBackground()
156
+ }
157
+
158
+ var showBackground: Boolean = true
159
+ set(value) {
160
+ field = value
161
+ applyBackground()
162
+ }
163
+
164
+ var showPlayButton: Boolean = true
165
+ set(value) {
166
+ field = value
167
+ playButton.visibility = if (value) View.VISIBLE else View.GONE
168
+ }
169
+
170
+ var playButtonColor: Int = Color.WHITE
171
+ set(value) {
172
+ field = value
173
+ playButton.iconColor = value
174
+ }
175
+
176
+ var showTime: Boolean = true
177
+ set(value) {
178
+ field = value
179
+ timeLabel.visibility = if (value) View.VISIBLE else View.GONE
180
+ updateRightStackVisibility()
181
+ }
182
+
183
+ var timeColor: Int = Color.WHITE
184
+ set(value) {
185
+ field = value
186
+ timeLabel.setTextColor(value)
187
+ }
188
+
189
+ var timeMode: String = "count-up"
190
+ set(value) {
191
+ field = value
192
+ updateTimeLabel()
193
+ }
194
+
195
+ var showSpeedControl: Boolean = true
196
+ set(value) {
197
+ field = value
198
+ speedPill.visibility = if (value) View.VISIBLE else View.GONE
199
+ updateRightStackVisibility()
200
+ }
201
+
202
+ var speedColor: Int = Color.WHITE
203
+ set(value) {
204
+ field = value
205
+ speedPill.setTextColor(value)
206
+ }
207
+
208
+ var speedBackgroundColor: Int = Color.argb(64, 255, 255, 255)
209
+ set(value) {
210
+ field = value
211
+ speedPill.pillColor = value
212
+ }
213
+
214
+ var speeds: FloatArray = floatArrayOf(0.5f, 1.0f, 1.5f, 2.0f)
215
+ set(value) {
216
+ field = if (value.isEmpty()) floatArrayOf(0.5f, 1.0f, 1.5f, 2.0f) else value
217
+ }
218
+
219
+ var defaultSpeed: Float = 1.0f
220
+ set(value) {
221
+ field = value
222
+ if (!defaultSpeedApplied) {
223
+ applyEffectiveSpeed(value)
224
+ }
225
+ }
226
+
227
+ var autoPlay: Boolean = false
228
+ var initialPositionMs: Int = 0
229
+ var loopPlayback: Boolean = false
230
+ set(value) {
231
+ field = value
232
+ engine.loop = value
233
+ }
234
+
235
+ /**
236
+ * Whether playback should continue when the host app is backgrounded.
237
+ * Default `false` — we pause on `LifecycleEventListener.onHostPause`.
238
+ * When `true`, we also opt into `MediaPlayer.setWakeMode` so playback
239
+ * can survive device sleep (requires `WAKE_LOCK` permission in the host
240
+ * app manifest; otherwise `setWakeMode` is silently skipped).
241
+ */
242
+ var playInBackground: Boolean = false
243
+ set(value) {
244
+ field = value
245
+ engine.setBackgroundPlaybackEnabled(context, value)
246
+ }
247
+
248
+ /**
249
+ * While the host app is backgrounded, skip the bars / time-label refreshes
250
+ * that would otherwise piggy-back on every 30 Hz progress tick. The JS
251
+ * `onTimeUpdate` event keeps firing regardless. Default `true`.
252
+ */
253
+ var pauseUiUpdatesInBackground: Boolean = true
254
+
255
+ /** -1 = uncontrolled, 0 = paused, 1 = playing. */
256
+ var controlledPlaying: Int = -1
257
+ set(value) {
258
+ field = value
259
+ applyControlledState()
260
+ }
261
+
262
+ /** < 0 = uncontrolled, otherwise the rate to apply. */
263
+ var controlledSpeed: Float = -1f
264
+ set(value) {
265
+ field = value
266
+ applyControlledState()
267
+ }
268
+
269
+ var sourceUri: String = ""
270
+ set(value) {
271
+ if (field == value) return
272
+ field = value
273
+ applySource()
274
+ }
275
+
276
+ var providedSamples: FloatArray? = null
277
+ set(value) {
278
+ field = value
279
+ applyProvidedSamples()
280
+ }
281
+
282
+ // endregion
283
+
284
+ // region Event callbacks (wired by the view manager) ------------------------
285
+
286
+ var onLoad: ((Int) -> Unit)? = null
287
+ var onLoadError: ((String) -> Unit)? = null
288
+ var onPlayerStateChange: ((String, Boolean, Float, String) -> Unit)? = null
289
+ var onTimeUpdate: ((Int, Int) -> Unit)? = null
290
+ var onSeek: ((Int) -> Unit)? = null
291
+ var onEnd: (() -> Unit)? = null
292
+
293
+ // endregion
294
+
295
+ init {
296
+ background = backgroundDrawable
297
+ clipToOutline = true
298
+
299
+ playButton.setOnClickListener { handlePlayButtonTap() }
300
+
301
+ speedPill.onTap = { handleSpeedPillTap() }
302
+
303
+ barsView.onScrubBegan = { fraction -> handleScrubBegan(fraction) }
304
+ barsView.onScrubMoved = { fraction -> handleScrubMoved(fraction) }
305
+ barsView.onScrubEnded = { fraction, cancelled -> handleScrubEnded(fraction, cancelled) }
306
+
307
+ rightStack.addView(timeLabel, LinearLayout.LayoutParams(
308
+ LinearLayout.LayoutParams.WRAP_CONTENT,
309
+ LinearLayout.LayoutParams.WRAP_CONTENT
310
+ ).apply {
311
+ gravity = Gravity.END
312
+ bottomMargin = (4f * density).toInt()
313
+ })
314
+ rightStack.addView(speedPill, LinearLayout.LayoutParams(
315
+ (44f * density).toInt(),
316
+ (22f * density).toInt()
317
+ ).apply {
318
+ gravity = Gravity.END
319
+ })
320
+
321
+ rowContainer.addView(playButton, LinearLayout.LayoutParams(
322
+ (32f * density).toInt(),
323
+ (32f * density).toInt()
324
+ ).apply {
325
+ marginEnd = (8f * density).toInt()
326
+ })
327
+ rowContainer.addView(barsView, LinearLayout.LayoutParams(
328
+ 0,
329
+ ViewGroup.LayoutParams.MATCH_PARENT,
330
+ 1f
331
+ ))
332
+ rowContainer.addView(rightStack, LinearLayout.LayoutParams(
333
+ ViewGroup.LayoutParams.WRAP_CONTENT,
334
+ ViewGroup.LayoutParams.WRAP_CONTENT
335
+ ).apply {
336
+ marginStart = (8f * density).toInt()
337
+ })
338
+
339
+ addView(rowContainer, LayoutParams(
340
+ ViewGroup.LayoutParams.MATCH_PARENT,
341
+ ViewGroup.LayoutParams.MATCH_PARENT
342
+ ))
343
+
344
+ wireEngineCallbacks()
345
+ (context as? ReactContext)?.addLifecycleEventListener(this)
346
+ }
347
+
348
+ // region Host lifecycle (React Native) --------------------------------------
349
+
350
+ override fun onHostResume() {
351
+ isBackgrounded = false
352
+ // Snap UI to the engine's current state in case we skipped tick
353
+ // updates while backgrounded.
354
+ val dur = engine.durationMs
355
+ val cur = engine.currentMs
356
+ if (dur > 0) {
357
+ barsView.progressFraction = cur.toFloat() / dur.toFloat()
358
+ }
359
+ updateTimeLabel(cur, dur)
360
+ playButton.isPlaying = engine.isPlaying
361
+ }
362
+
363
+ override fun onHostPause() {
364
+ isBackgrounded = true
365
+ if (playInBackground) return
366
+ if (engine.isPlaying) {
367
+ engine.pause()
368
+ }
369
+ }
370
+
371
+ override fun onHostDestroy() {
372
+ engine.reset()
373
+ decoder.cancel()
374
+ }
375
+
376
+ // endregion
377
+
378
+ // region Source / samples ----------------------------------------------------
379
+
380
+ private fun applySource() {
381
+ val uri = sourceUri
382
+ if (uri.isEmpty()) {
383
+ currentSourceUri = null
384
+ engine.reset()
385
+ return
386
+ }
387
+ currentSourceUri = uri
388
+ initialPositionApplied = false
389
+ engine.setSource(context, uri)
390
+ emitPlayerState()
391
+
392
+ if (!samplesProvided) {
393
+ decodeAmplitudesIfPossible()
394
+ }
395
+ }
396
+
397
+ private fun applyProvidedSamples() {
398
+ val provided = providedSamples
399
+ if (provided == null || provided.isEmpty()) {
400
+ samplesProvided = false
401
+ decodeAmplitudesIfPossible()
402
+ return
403
+ }
404
+ samplesProvided = true
405
+ decoder.cancel()
406
+ // Renormalise in case caller passed values >1.
407
+ val maxV = provided.maxOrNull() ?: 0f
408
+ val finalAmps = if (maxV <= 0f) {
409
+ FloatArray(provided.size)
410
+ } else if (maxV <= 1f) {
411
+ provided.copyOf()
412
+ } else {
413
+ FloatArray(provided.size) { (provided[it] / maxV).coerceIn(0f, 1f) }
414
+ }
415
+ barsView.setAmplitudes(finalAmps)
416
+ }
417
+
418
+ private fun decodeAmplitudesIfPossible() {
419
+ if (samplesProvided) return
420
+ val uri = currentSourceUri ?: return
421
+ // Use a sensible bar count; the bars view re-buckets to its own
422
+ // count at draw time so this just needs to be reasonably granular.
423
+ val provisional = if (barsView.width > 0) {
424
+ ((barsView.width / (barsView.barWidthPx + barsView.barGapPx)).toInt()).coerceAtLeast(8)
425
+ } else 80
426
+ decoder.decode(uri, provisional, object : WaveformDecoder.Listener {
427
+ override fun onProgress(amplitudes: FloatArray) {
428
+ barsView.setAmplitudes(amplitudes)
429
+ }
430
+ override fun onComplete(amplitudes: FloatArray) {
431
+ barsView.setAmplitudes(amplitudes)
432
+ }
433
+ override fun onFailure(message: String) {
434
+ onLoadError?.invoke(message)
435
+ }
436
+ })
437
+ }
438
+
439
+ // endregion
440
+
441
+ // region Background ----------------------------------------------------------
442
+
443
+ private fun applyBackground() {
444
+ background = if (showBackground) backgroundDrawable else null
445
+ }
446
+
447
+ // endregion
448
+
449
+ // region Engine plumbing -----------------------------------------------------
450
+
451
+ private fun wireEngineCallbacks() {
452
+ engine.onLoad = { duration ->
453
+ onLoad?.invoke(duration)
454
+ if (!initialPositionApplied && initialPositionMs > 0) {
455
+ engine.seekToMs(initialPositionMs)
456
+ }
457
+ initialPositionApplied = true
458
+ // Honour autoplay / controlled state once the source is ready.
459
+ if (controlledPlaying == 1) {
460
+ engine.play()
461
+ } else if (controlledPlaying == -1 && autoPlay) {
462
+ engine.play()
463
+ }
464
+ emitPlayerState()
465
+ }
466
+ engine.onLoadError = { message ->
467
+ onLoadError?.invoke(message)
468
+ emitPlayerState(error = message)
469
+ }
470
+ engine.onStateChange = {
471
+ // Order matters: update `isPlaying` *before* `isLoading`. While
472
+ // the spinner is still showing the icon swap is invisible to
473
+ // the user; then when we drop the spinner the imageView is
474
+ // already pointing at the right icon (no crossfade flash if
475
+ // a tap was queued during loading).
476
+ playButton.isPlaying = engine.isPlaying
477
+ playButton.isLoading = (engine.state == AudioPlayerEngine.State.LOADING)
478
+ emitPlayerState()
479
+ }
480
+ engine.onTimeUpdate = { currentMs, durationMs ->
481
+ if (!isScrubbing) {
482
+ // JS event always fires (callers may want it for now-playing UI).
483
+ onTimeUpdate?.invoke(currentMs, durationMs)
484
+ // Skip the cheap-but-pointless UI work while backgrounded.
485
+ if (!(isBackgrounded && pauseUiUpdatesInBackground)) {
486
+ barsView.progressFraction = if (durationMs > 0) {
487
+ currentMs.toFloat() / durationMs.toFloat()
488
+ } else 0f
489
+ updateTimeLabel(currentMs, durationMs)
490
+ }
491
+ }
492
+ }
493
+ engine.onEnded = {
494
+ onEnd?.invoke()
495
+ playButton.isPlaying = false
496
+ emitPlayerState()
497
+ }
498
+ }
499
+
500
+ private fun emitPlayerState(error: String? = null) {
501
+ val stateString = when (engine.state) {
502
+ AudioPlayerEngine.State.IDLE -> "idle"
503
+ AudioPlayerEngine.State.LOADING -> "loading"
504
+ AudioPlayerEngine.State.READY -> "ready"
505
+ AudioPlayerEngine.State.ENDED -> "ended"
506
+ AudioPlayerEngine.State.ERROR -> "error"
507
+ }
508
+ onPlayerStateChange?.invoke(
509
+ stateString,
510
+ engine.isPlaying,
511
+ effectiveSpeed(),
512
+ error ?: ""
513
+ )
514
+ }
515
+
516
+ private fun updateRightStackVisibility() {
517
+ rightStack.visibility = if (showTime || showSpeedControl) View.VISIBLE else View.GONE
518
+ }
519
+
520
+ // endregion
521
+
522
+ // region Time / speed --------------------------------------------------------
523
+
524
+ private fun updateTimeLabel(currentMs: Int = engine.currentMs, durationMs: Int = engine.durationMs) {
525
+ val display = if (timeMode.equals("count-down", ignoreCase = true)) {
526
+ (durationMs - currentMs).coerceAtLeast(0)
527
+ } else currentMs
528
+ val totalSeconds = display / 1000
529
+ val minutes = totalSeconds / 60
530
+ val seconds = totalSeconds % 60
531
+ timeLabel.text = String.format("%d:%02d", minutes, seconds)
532
+ }
533
+
534
+ private fun effectiveSpeed(): Float =
535
+ if (controlledSpeed >= 0) controlledSpeed else internalSpeed
536
+
537
+ private fun nextSpeed(current: Float): Float {
538
+ if (speeds.isEmpty()) return 1.0f
539
+ val next = speeds.firstOrNull { it > current + 0.001f }
540
+ return next ?: speeds.first()
541
+ }
542
+
543
+ private fun applyEffectiveSpeed(rate: Float) {
544
+ if (controlledSpeed < 0) {
545
+ internalSpeed = rate
546
+ defaultSpeedApplied = true
547
+ }
548
+ engine.setRate(rate)
549
+ speedPill.setSpeed(rate)
550
+ emitPlayerState()
551
+ }
552
+
553
+ private fun applyControlledState() {
554
+ if (controlledSpeed >= 0) {
555
+ engine.setRate(controlledSpeed)
556
+ speedPill.setSpeed(controlledSpeed)
557
+ }
558
+ when (controlledPlaying) {
559
+ 0 -> if (engine.isPlaying) engine.pause()
560
+ // The engine's `play()` understands `LOADING` and queues a
561
+ // pending start, so we forward the intent regardless of
562
+ // state and let the engine resume playback the moment
563
+ // buffering finishes (or no-op if we're in IDLE / ERROR).
564
+ 1 -> engine.play()
565
+ }
566
+ emitPlayerState()
567
+ }
568
+
569
+ // endregion
570
+
571
+ // region Action handlers -----------------------------------------------------
572
+
573
+ private fun handlePlayButtonTap() {
574
+ if (controlledPlaying != -1) {
575
+ // Controlled — fire event with requested *new* state, but don't toggle.
576
+ val newPlaying = !engine.isPlaying
577
+ playButton.isPlaying = engine.isPlaying // restore visual state
578
+ onPlayerStateChange?.invoke(
579
+ stateString(),
580
+ newPlaying,
581
+ effectiveSpeed(),
582
+ ""
583
+ )
584
+ return
585
+ }
586
+ engine.toggle()
587
+ playButton.isPlaying = engine.isPlaying
588
+ }
589
+
590
+ private fun handleSpeedPillTap() {
591
+ val current = effectiveSpeed()
592
+ val next = nextSpeed(current)
593
+ if (controlledSpeed >= 0) {
594
+ onPlayerStateChange?.invoke(
595
+ stateString(),
596
+ engine.isPlaying,
597
+ next,
598
+ ""
599
+ )
600
+ return
601
+ }
602
+ applyEffectiveSpeed(next)
603
+ }
604
+
605
+ private fun stateString(): String = when (engine.state) {
606
+ AudioPlayerEngine.State.IDLE -> "idle"
607
+ AudioPlayerEngine.State.LOADING -> "loading"
608
+ AudioPlayerEngine.State.READY -> "ready"
609
+ AudioPlayerEngine.State.ENDED -> "ended"
610
+ AudioPlayerEngine.State.ERROR -> "error"
611
+ }
612
+
613
+ // endregion
614
+
615
+ // region Scrub handlers ------------------------------------------------------
616
+
617
+ private fun handleScrubBegan(fraction: Float) {
618
+ isScrubbing = true
619
+ resumeAfterScrub = engine.isPlaying
620
+ if (engine.isPlaying) engine.pause()
621
+ val pos = positionFromFraction(fraction)
622
+ engine.seekToMs(pos)
623
+ updateTimeLabel(pos, engine.durationMs)
624
+ }
625
+
626
+ private fun handleScrubMoved(fraction: Float) {
627
+ val pos = positionFromFraction(fraction)
628
+ engine.seekToMs(pos)
629
+ updateTimeLabel(pos, engine.durationMs)
630
+ }
631
+
632
+ private fun handleScrubEnded(fraction: Float, cancelled: Boolean) {
633
+ isScrubbing = false
634
+ val pos = positionFromFraction(fraction)
635
+ engine.seekToMs(pos)
636
+ updateTimeLabel(pos, engine.durationMs)
637
+ onSeek?.invoke(pos)
638
+ if (!cancelled && resumeAfterScrub && controlledPlaying != 0) {
639
+ engine.play()
640
+ }
641
+ }
642
+
643
+ private fun positionFromFraction(fraction: Float): Int {
644
+ val dur = engine.durationMs
645
+ if (dur <= 0) return 0
646
+ return (fraction.coerceIn(0f, 1f) * dur).toInt()
647
+ }
648
+
649
+ // endregion
650
+
651
+ // region Imperative commands (called by the view manager) -------------------
652
+
653
+ fun play() {
654
+ if (controlledPlaying != -1) return
655
+ engine.play()
656
+ }
657
+
658
+ fun pause() {
659
+ if (controlledPlaying != -1) return
660
+ engine.pause()
661
+ }
662
+
663
+ fun toggle() {
664
+ if (controlledPlaying != -1) return
665
+ engine.toggle()
666
+ }
667
+
668
+ fun seekTo(ms: Int) {
669
+ engine.seekToMs(ms)
670
+ updateTimeLabel(ms, engine.durationMs)
671
+ onSeek?.invoke(ms)
672
+ }
673
+
674
+ fun setSpeedValue(value: Float) {
675
+ if (controlledSpeed >= 0) return
676
+ applyEffectiveSpeed(value)
677
+ }
678
+
679
+ // endregion
680
+
681
+ // region Bridge helpers -----------------------------------------------------
682
+
683
+ fun setSpeedsFromArray(value: ReadableArray?) {
684
+ if (value == null || value.size() == 0) {
685
+ speeds = floatArrayOf(0.5f, 1.0f, 1.5f, 2.0f)
686
+ return
687
+ }
688
+ val arr = FloatArray(value.size())
689
+ for (i in 0 until value.size()) {
690
+ arr[i] = value.getDouble(i).toFloat()
691
+ }
692
+ speeds = arr
693
+ }
694
+
695
+ fun setSamplesFromArray(value: ReadableArray?) {
696
+ if (value == null || value.size() == 0) {
697
+ providedSamples = null
698
+ return
699
+ }
700
+ val arr = FloatArray(value.size())
701
+ for (i in 0 until value.size()) {
702
+ arr[i] = value.getDouble(i).toFloat()
703
+ }
704
+ providedSamples = arr
705
+ }
706
+
707
+ // endregion
708
+
709
+ override fun onDetachedFromWindow() {
710
+ super.onDetachedFromWindow()
711
+ (context as? ReactContext)?.removeLifecycleEventListener(this)
712
+ engine.reset()
713
+ decoder.cancel()
714
+ }
715
+ }