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