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,353 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.media.MediaPlayer
|
|
6
|
+
import android.media.PlaybackParams
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.Looper
|
|
11
|
+
import android.os.PowerManager
|
|
12
|
+
import android.util.Log
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Thin wrapper around the built-in `android.media.MediaPlayer` that exposes
|
|
16
|
+
* the events the rest of the component needs: load lifecycle, periodic time
|
|
17
|
+
* updates, end-of-track, and rate / seek control. All callbacks fire on the
|
|
18
|
+
* main thread.
|
|
19
|
+
*/
|
|
20
|
+
class AudioPlayerEngine {
|
|
21
|
+
|
|
22
|
+
enum class State { IDLE, LOADING, READY, ENDED, ERROR }
|
|
23
|
+
|
|
24
|
+
var state: State = State.IDLE
|
|
25
|
+
private set
|
|
26
|
+
|
|
27
|
+
var durationMs: Int = 0
|
|
28
|
+
private set
|
|
29
|
+
|
|
30
|
+
var currentMs: Int = 0
|
|
31
|
+
private set
|
|
32
|
+
|
|
33
|
+
var isPlaying: Boolean = false
|
|
34
|
+
private set
|
|
35
|
+
|
|
36
|
+
var rate: Float = 1.0f
|
|
37
|
+
private set
|
|
38
|
+
|
|
39
|
+
var loop: Boolean = false
|
|
40
|
+
|
|
41
|
+
// Callbacks
|
|
42
|
+
var onLoad: ((Int) -> Unit)? = null
|
|
43
|
+
var onLoadError: ((String) -> Unit)? = null
|
|
44
|
+
var onStateChange: (() -> Unit)? = null
|
|
45
|
+
var onTimeUpdate: ((Int, Int) -> Unit)? = null
|
|
46
|
+
var onEnded: (() -> Unit)? = null
|
|
47
|
+
|
|
48
|
+
private var player: MediaPlayer? = null
|
|
49
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
50
|
+
private val progressRunnable = object : Runnable {
|
|
51
|
+
override fun run() {
|
|
52
|
+
val p = player ?: return
|
|
53
|
+
if (state != State.READY || !isPlaying) return
|
|
54
|
+
try {
|
|
55
|
+
currentMs = p.currentPosition.coerceIn(0, durationMs)
|
|
56
|
+
onTimeUpdate?.invoke(currentMs, durationMs)
|
|
57
|
+
} catch (_: IllegalStateException) {
|
|
58
|
+
// Player isn't ready / has been released; just stop polling.
|
|
59
|
+
}
|
|
60
|
+
mainHandler.postDelayed(this, 33L)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Cached rate to be applied once the player transitions to Started. */
|
|
65
|
+
private var pendingRate: Float? = null
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* "Play once ready" intent recorded if `play()` is called while the
|
|
69
|
+
* player is still in `LOADING`. Cleared on pause / reset / source
|
|
70
|
+
* change. Resumed automatically when we transition into `READY`.
|
|
71
|
+
*/
|
|
72
|
+
private var pendingStart: Boolean = false
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cached "would like a partial wake lock" flag. Applied each time the
|
|
76
|
+
* underlying `MediaPlayer` is (re-)created. Requires the host app to
|
|
77
|
+
* declare `WAKE_LOCK` in its manifest — without it `setWakeMode` throws
|
|
78
|
+
* `SecurityException`, which we catch and ignore so playback still works
|
|
79
|
+
* (it just won't survive device sleep with the screen off).
|
|
80
|
+
*/
|
|
81
|
+
private var wakeModeEnabled: Boolean = false
|
|
82
|
+
|
|
83
|
+
fun setSource(context: android.content.Context, uri: String) {
|
|
84
|
+
reset()
|
|
85
|
+
setStateInternal(State.LOADING)
|
|
86
|
+
try {
|
|
87
|
+
val mp = MediaPlayer()
|
|
88
|
+
applyWakeMode(context, mp)
|
|
89
|
+
mp.setOnPreparedListener { prepared ->
|
|
90
|
+
durationMs = try { prepared.duration.coerceAtLeast(0) } catch (_: Exception) { 0 }
|
|
91
|
+
setStateInternal(State.READY)
|
|
92
|
+
onLoad?.invoke(durationMs)
|
|
93
|
+
}
|
|
94
|
+
mp.setOnCompletionListener {
|
|
95
|
+
if (loop) {
|
|
96
|
+
try {
|
|
97
|
+
it.seekTo(0)
|
|
98
|
+
currentMs = 0
|
|
99
|
+
if (isPlaying) {
|
|
100
|
+
it.start()
|
|
101
|
+
applyPendingRate()
|
|
102
|
+
}
|
|
103
|
+
} catch (_: Exception) {}
|
|
104
|
+
} else {
|
|
105
|
+
isPlaying = false
|
|
106
|
+
currentMs = durationMs
|
|
107
|
+
setStateInternal(State.ENDED)
|
|
108
|
+
onTimeUpdate?.invoke(currentMs, durationMs)
|
|
109
|
+
onEnded?.invoke()
|
|
110
|
+
stopProgressLoop()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
mp.setOnErrorListener { _, what, extra ->
|
|
114
|
+
isPlaying = false
|
|
115
|
+
stopProgressLoop()
|
|
116
|
+
setStateInternal(State.ERROR)
|
|
117
|
+
onLoadError?.invoke("MediaPlayer error: what=$what extra=$extra")
|
|
118
|
+
true
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
if (uri.startsWith("http://") || uri.startsWith("https://")) {
|
|
122
|
+
mp.setDataSource(uri)
|
|
123
|
+
} else {
|
|
124
|
+
mp.setDataSource(context, Uri.parse(uri))
|
|
125
|
+
}
|
|
126
|
+
} catch (e: Exception) {
|
|
127
|
+
Log.e(TAG, "setDataSource failed", e)
|
|
128
|
+
setStateInternal(State.ERROR)
|
|
129
|
+
onLoadError?.invoke("setDataSource failed: ${e.message}")
|
|
130
|
+
mp.release()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
mp.prepareAsync()
|
|
135
|
+
} catch (e: Exception) {
|
|
136
|
+
Log.e(TAG, "prepareAsync failed", e)
|
|
137
|
+
setStateInternal(State.ERROR)
|
|
138
|
+
onLoadError?.invoke("prepareAsync failed: ${e.message}")
|
|
139
|
+
mp.release()
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
player = mp
|
|
143
|
+
} catch (e: Exception) {
|
|
144
|
+
Log.e(TAG, "Failed to create MediaPlayer", e)
|
|
145
|
+
setStateInternal(State.ERROR)
|
|
146
|
+
onLoadError?.invoke(e.message ?: "Unknown error")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fun play() {
|
|
151
|
+
if (state == State.LOADING) {
|
|
152
|
+
// Audio isn't buffered yet — record the intent and let the
|
|
153
|
+
// `READY` transition resume playback automatically. We keep
|
|
154
|
+
// the play button as the loading spinner instead of briefly
|
|
155
|
+
// flashing a "pause" icon while the buffer fills.
|
|
156
|
+
pendingStart = true
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
val p = player ?: return
|
|
160
|
+
if (state != State.READY && state != State.ENDED) return
|
|
161
|
+
// Already running — skip so `applyControlledState()` doesn't
|
|
162
|
+
// fire a redundant `onStateChange` on every prop update.
|
|
163
|
+
if (isPlaying && state == State.READY) return
|
|
164
|
+
try {
|
|
165
|
+
pendingStart = false
|
|
166
|
+
if (state == State.ENDED) {
|
|
167
|
+
p.seekTo(0)
|
|
168
|
+
currentMs = 0
|
|
169
|
+
setStateInternal(State.READY)
|
|
170
|
+
}
|
|
171
|
+
p.start()
|
|
172
|
+
// Only flip to "playing" once start() succeeds — otherwise we'd
|
|
173
|
+
// leave the play/pause icon stuck on the pause symbol while no
|
|
174
|
+
// audio is actually playing.
|
|
175
|
+
isPlaying = true
|
|
176
|
+
applyPendingRate()
|
|
177
|
+
startProgressLoop()
|
|
178
|
+
onStateChange?.invoke()
|
|
179
|
+
} catch (e: Exception) {
|
|
180
|
+
// Defensive: if start() threw (e.g. SecurityException from a wake
|
|
181
|
+
// lock acquisition), make sure we don't leave isPlaying=true.
|
|
182
|
+
isPlaying = false
|
|
183
|
+
stopProgressLoop()
|
|
184
|
+
Log.e(TAG, "play failed", e)
|
|
185
|
+
onStateChange?.invoke()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fun pause() {
|
|
190
|
+
// Cancel any queued "play once ready" intent — the user explicitly
|
|
191
|
+
// wants playback to stay paused.
|
|
192
|
+
pendingStart = false
|
|
193
|
+
val p = player ?: return
|
|
194
|
+
if (!isPlaying) return
|
|
195
|
+
try {
|
|
196
|
+
p.pause()
|
|
197
|
+
isPlaying = false
|
|
198
|
+
stopProgressLoop()
|
|
199
|
+
onStateChange?.invoke()
|
|
200
|
+
} catch (e: Exception) {
|
|
201
|
+
Log.e(TAG, "pause failed", e)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fun toggle() {
|
|
206
|
+
if (isPlaying) pause() else play()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fun seekToMs(ms: Int) {
|
|
210
|
+
val p = player ?: return
|
|
211
|
+
val clamped = ms.coerceIn(0, durationMs.coerceAtLeast(0))
|
|
212
|
+
currentMs = clamped
|
|
213
|
+
try {
|
|
214
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
215
|
+
p.seekTo(clamped.toLong(), MediaPlayer.SEEK_CLOSEST)
|
|
216
|
+
} else {
|
|
217
|
+
p.seekTo(clamped)
|
|
218
|
+
}
|
|
219
|
+
} catch (e: Exception) {
|
|
220
|
+
Log.e(TAG, "seekTo failed", e)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fun setRate(newRate: Float) {
|
|
225
|
+
val clamped = newRate.coerceIn(0.25f, 4.0f)
|
|
226
|
+
rate = clamped
|
|
227
|
+
val p = player ?: return
|
|
228
|
+
if (isPlaying) {
|
|
229
|
+
try {
|
|
230
|
+
// setPlaybackParams on a started player works fine.
|
|
231
|
+
p.playbackParams = PlaybackParams().setSpeed(clamped)
|
|
232
|
+
pendingRate = null
|
|
233
|
+
} catch (e: Exception) {
|
|
234
|
+
Log.e(TAG, "setPlaybackParams failed", e)
|
|
235
|
+
pendingRate = clamped
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
// Some OEMs auto-resume playback when setPlaybackParams is called
|
|
239
|
+
// on a paused player; cache the rate and apply at next start().
|
|
240
|
+
pendingRate = clamped
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Toggle whether `MediaPlayer.setWakeMode(PARTIAL_WAKE_LOCK)` should be
|
|
246
|
+
* applied. Takes effect on the next `setSource(...)` call (and the
|
|
247
|
+
* current player, if any).
|
|
248
|
+
*/
|
|
249
|
+
fun setBackgroundPlaybackEnabled(context: android.content.Context, enabled: Boolean) {
|
|
250
|
+
wakeModeEnabled = enabled
|
|
251
|
+
player?.let { applyWakeMode(context, it) }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private fun applyWakeMode(context: android.content.Context, mp: MediaPlayer) {
|
|
255
|
+
if (!wakeModeEnabled) return
|
|
256
|
+
// Must check the permission **before** calling `setWakeMode` — the call
|
|
257
|
+
// itself doesn't throw if WAKE_LOCK is missing; instead, the underlying
|
|
258
|
+
// `WakeLock.acquire()` later inside `MediaPlayer.start()` throws
|
|
259
|
+
// SecurityException, which silently breaks playback. So we gate the
|
|
260
|
+
// setWakeMode call on the permission being granted up-front.
|
|
261
|
+
val granted = context.checkSelfPermission(Manifest.permission.WAKE_LOCK) ==
|
|
262
|
+
PackageManager.PERMISSION_GRANTED
|
|
263
|
+
if (!granted) {
|
|
264
|
+
Log.w(
|
|
265
|
+
TAG,
|
|
266
|
+
"playInBackground=true but WAKE_LOCK permission is not granted — skipping " +
|
|
267
|
+
"MediaPlayer.setWakeMode. Background playback still works while the screen " +
|
|
268
|
+
"is on. To survive device sleep, add " +
|
|
269
|
+
"`<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>` " +
|
|
270
|
+
"to your app manifest."
|
|
271
|
+
)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
mp.setWakeMode(context.applicationContext, PowerManager.PARTIAL_WAKE_LOCK)
|
|
276
|
+
} catch (e: Exception) {
|
|
277
|
+
Log.w(TAG, "setWakeMode failed", e)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private fun applyPendingRate() {
|
|
282
|
+
val p = player ?: return
|
|
283
|
+
val target = pendingRate ?: rate
|
|
284
|
+
try {
|
|
285
|
+
p.playbackParams = PlaybackParams().setSpeed(target)
|
|
286
|
+
pendingRate = null
|
|
287
|
+
} catch (e: Exception) {
|
|
288
|
+
Log.e(TAG, "applyPendingRate failed", e)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fun reset() {
|
|
293
|
+
stopProgressLoop()
|
|
294
|
+
try { player?.reset() } catch (_: Exception) {}
|
|
295
|
+
try { player?.release() } catch (_: Exception) {}
|
|
296
|
+
player = null
|
|
297
|
+
isPlaying = false
|
|
298
|
+
currentMs = 0
|
|
299
|
+
durationMs = 0
|
|
300
|
+
rate = 1.0f
|
|
301
|
+
pendingRate = null
|
|
302
|
+
pendingStart = false
|
|
303
|
+
setStateInternal(State.IDLE)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun setStateInternal(newState: State) {
|
|
307
|
+
if (state == newState) return
|
|
308
|
+
state = newState
|
|
309
|
+
// If the user tapped "play" while we were still loading, apply
|
|
310
|
+
// playback synchronously *before* firing `onStateChange` so the
|
|
311
|
+
// single notification reflects the final state (state == READY
|
|
312
|
+
// AND isPlaying == true). This avoids a brief play-icon flash
|
|
313
|
+
// between "spinner stops" and "playback actually starts".
|
|
314
|
+
if (newState == State.READY && pendingStart) {
|
|
315
|
+
pendingStart = false
|
|
316
|
+
tryStartPlaybackInternal()
|
|
317
|
+
}
|
|
318
|
+
onStateChange?.invoke()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Common play-start sequence shared by `play()` and the in-line
|
|
323
|
+
* resume from `setStateInternal()` when transitioning to `READY`
|
|
324
|
+
* with a queued tap intent. Does NOT fire `onStateChange` — callers
|
|
325
|
+
* are responsible for that (so we can batch a single notification).
|
|
326
|
+
*/
|
|
327
|
+
private fun tryStartPlaybackInternal() {
|
|
328
|
+
val p = player ?: return
|
|
329
|
+
try {
|
|
330
|
+
p.start()
|
|
331
|
+
isPlaying = true
|
|
332
|
+
applyPendingRate()
|
|
333
|
+
startProgressLoop()
|
|
334
|
+
} catch (e: Exception) {
|
|
335
|
+
isPlaying = false
|
|
336
|
+
stopProgressLoop()
|
|
337
|
+
Log.e(TAG, "deferred play failed", e)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private fun startProgressLoop() {
|
|
342
|
+
stopProgressLoop()
|
|
343
|
+
mainHandler.post(progressRunnable)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private fun stopProgressLoop() {
|
|
347
|
+
mainHandler.removeCallbacks(progressRunnable)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
companion object {
|
|
351
|
+
private const val TAG = "AudioPlayerEngine"
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.WritableMap
|
|
4
|
+
import com.facebook.react.uimanager.events.Event
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generic event wrapper for the AudioWaveformView. We dispatch one of these
|
|
8
|
+
* with a specific `eventName` ("topLoad" / "topLoadError" / "topPlayerStateChange" /
|
|
9
|
+
* "topTimeUpdate" / "topSeek" / "topEnd") and a per-event `WritableMap` payload.
|
|
10
|
+
*
|
|
11
|
+
* The `top` prefix is the legacy event-bubbling convention; codegen routes it
|
|
12
|
+
* to the corresponding `on<Name>` prop on the JS side under both architectures.
|
|
13
|
+
*/
|
|
14
|
+
class AudioWaveformEvent(
|
|
15
|
+
surfaceId: Int,
|
|
16
|
+
viewTag: Int,
|
|
17
|
+
private val name: String,
|
|
18
|
+
private val payload: WritableMap
|
|
19
|
+
) : Event<AudioWaveformEvent>(surfaceId, viewTag) {
|
|
20
|
+
override fun getEventName(): String = name
|
|
21
|
+
override fun getEventData(): WritableMap = payload
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
7
|
+
import com.facebook.react.uimanager.ViewManager
|
|
8
|
+
|
|
9
|
+
class AudioWaveformViewPackage : BaseReactPackage() {
|
|
10
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
11
|
+
return listOf(AudioWaveformViewManager(reactContext))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
|
|
15
|
+
|
|
16
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { emptyMap() }
|
|
17
|
+
}
|