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