expo-gliph-player 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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -0
  3. package/android/build.gradle +82 -0
  4. package/android/src/main/AndroidManifest.xml +31 -0
  5. package/android/src/main/java/com/gliphplayer/DeviceInfoModule.kt +48 -0
  6. package/android/src/main/java/com/gliphplayer/DeviceInfoPackage.kt +16 -0
  7. package/android/src/main/java/com/gliphplayer/GliphPlayerModule.kt +360 -0
  8. package/android/src/main/java/com/gliphplayer/GliphPlayerPackage.kt +48 -0
  9. package/android/src/main/java/com/gliphplayer/GliphPlayerService.kt +797 -0
  10. package/android/src/main/res/xml/automotive_app_desc.xml +4 -0
  11. package/android/src/oldarch/com/gliphplayer/NativeGliphPlayerSpec.kt +56 -0
  12. package/app.plugin.js +1 -0
  13. package/expo-gliph-player.podspec +91 -0
  14. package/ios/GliphAudioPlayer.h +77 -0
  15. package/ios/GliphAudioPlayer.swift +697 -0
  16. package/ios/GliphPlayer-Bridging-Header.h +3 -0
  17. package/ios/GliphPlayerModule.h +12 -0
  18. package/ios/GliphPlayerModule.mm +243 -0
  19. package/ios/expo_gliph_player.h +22 -0
  20. package/lib/commonjs/GliphPlayer.js +310 -0
  21. package/lib/commonjs/GliphPlayer.js.map +1 -0
  22. package/lib/commonjs/hooks.js +233 -0
  23. package/lib/commonjs/hooks.js.map +1 -0
  24. package/lib/commonjs/index.js +166 -0
  25. package/lib/commonjs/index.js.map +1 -0
  26. package/lib/commonjs/package.json +1 -0
  27. package/lib/commonjs/specs/NativeGliphPlayer.js +19 -0
  28. package/lib/commonjs/specs/NativeGliphPlayer.js.map +1 -0
  29. package/lib/commonjs/types.js +175 -0
  30. package/lib/commonjs/types.js.map +1 -0
  31. package/lib/module/GliphPlayer.js +305 -0
  32. package/lib/module/GliphPlayer.js.map +1 -0
  33. package/lib/module/hooks.js +221 -0
  34. package/lib/module/hooks.js.map +1 -0
  35. package/lib/module/index.js +20 -0
  36. package/lib/module/index.js.map +1 -0
  37. package/lib/module/package.json +1 -0
  38. package/lib/module/specs/NativeGliphPlayer.js +20 -0
  39. package/lib/module/specs/NativeGliphPlayer.js.map +1 -0
  40. package/lib/module/types.js +181 -0
  41. package/lib/module/types.js.map +1 -0
  42. package/lib/typescript/src/GliphPlayer.d.ts +113 -0
  43. package/lib/typescript/src/GliphPlayer.d.ts.map +1 -0
  44. package/lib/typescript/src/hooks.d.ts +51 -0
  45. package/lib/typescript/src/hooks.d.ts.map +1 -0
  46. package/lib/typescript/src/index.d.ts +11 -0
  47. package/lib/typescript/src/index.d.ts.map +1 -0
  48. package/lib/typescript/src/specs/NativeGliphPlayer.d.ts +80 -0
  49. package/lib/typescript/src/specs/NativeGliphPlayer.d.ts.map +1 -0
  50. package/lib/typescript/src/types.d.ts +348 -0
  51. package/lib/typescript/src/types.d.ts.map +1 -0
  52. package/package.json +62 -0
  53. package/src/GliphPlayer.ts +356 -0
  54. package/src/hooks.ts +256 -0
  55. package/src/index.ts +61 -0
  56. package/src/specs/NativeGliphPlayer.ts +105 -0
  57. package/src/types.ts +395 -0
  58. package/src/withGliphPlayer.js +166 -0
@@ -0,0 +1,797 @@
1
+ package com.gliphplayer
2
+
3
+ import android.util.Log
4
+
5
+ import android.app.Notification
6
+ import android.app.NotificationManager
7
+ import android.app.PendingIntent
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.os.Binder
11
+ import android.os.IBinder
12
+ import androidx.media3.common.*
13
+ import androidx.media3.common.util.UnstableApi
14
+ import androidx.media3.exoplayer.ExoPlayer
15
+ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
16
+ import androidx.media3.session.*
17
+ import androidx.core.app.NotificationCompat
18
+ import android.content.pm.ServiceInfo
19
+ import com.facebook.react.bridge.*
20
+ import kotlinx.coroutines.*
21
+
22
+ /**
23
+ * GliphPlayerService
24
+ *
25
+ * Foreground service that owns the ExoPlayer instance and MediaSession.
26
+ * Survives app backgrounding and handles:
27
+ * - Audio playback via ExoPlayer (Media3)
28
+ * - MediaSession for lock screen / notification controls
29
+ * - Android Auto via MediaLibraryService
30
+ * - Audio focus management
31
+ * - Queue management
32
+ */
33
+ @UnstableApi
34
+ class GliphPlayerService : MediaLibraryService() {
35
+
36
+ companion object {
37
+ private const val CHANNEL_ID = "gliph_player_channel"
38
+ private const val NOTIFICATION_ID = 1001
39
+ private const val ACTION_PLAY = "com.gliphplayer.action.PLAY"
40
+ private const val ACTION_PAUSE = "com.gliphplayer.action.PAUSE"
41
+ private const val ACTION_SKIP_NEXT = "com.gliphplayer.action.SKIP_NEXT"
42
+ private const val ACTION_SKIP_PREVIOUS = "com.gliphplayer.action.SKIP_PREVIOUS"
43
+ private const val ACTION_STOP = "com.gliphplayer.action.STOP"
44
+ }
45
+
46
+ // ── Binder ──────────────────────────────────────────────────────────────────
47
+
48
+ inner class LocalBinder : Binder() {
49
+ fun getService(): GliphPlayerService = this@GliphPlayerService
50
+ }
51
+
52
+ private val binder = LocalBinder()
53
+
54
+ override fun onBind(intent: Intent?): IBinder? {
55
+ // MediaLibraryService.onBind handles media browser connections.
56
+ // For our local binding from GliphPlayerModule, we return our LocalBinder.
57
+ // We must check the action to distinguish the two callers.
58
+ val action = intent?.action
59
+ if (action == null ||
60
+ action == "com.gliphplayer.BIND_LOCAL" ||
61
+ action == Intent.ACTION_MAIN) {
62
+ return binder
63
+ }
64
+ // Let MediaLibraryService handle MediaBrowser / MediaSession connections
65
+ return super.onBind(intent)
66
+ }
67
+
68
+ // ── State ───────────────────────────────────────────────────────────────────
69
+
70
+ private lateinit var player: ExoPlayer
71
+ private lateinit var mediaSession: MediaLibrarySession
72
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
73
+
74
+ private var eventEmitter: ((String, WritableMap?) -> Unit)? = null
75
+ private var progressJob: Job? = null
76
+ private var options: ReadableMap? = null
77
+
78
+ // Internal queue (mirrors ExoPlayer's playlist)
79
+ private val queue = java.util.Collections.synchronizedList(mutableListOf<ReadableMap>())
80
+
81
+ // ── Helpers ─────────────────────────────────────────────────────────────────
82
+
83
+ private fun getDouble(map: ReadableMap?, key: String, default: Double): Double {
84
+ return if (map?.hasKey(key) == true) map.getDouble(key) else default
85
+ }
86
+
87
+ private fun getInt(map: ReadableMap?, key: String, default: Int): Int {
88
+ return if (map?.hasKey(key) == true) map.getInt(key) else default
89
+ }
90
+
91
+ private fun getString(map: ReadableMap?, key: String, default: String?): String? {
92
+ return if (map?.hasKey(key) == true) map.getString(key) else default
93
+ }
94
+
95
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
96
+
97
+ override fun onCreate() {
98
+ super.onCreate()
99
+ // 1. Create high-importance notification channel
100
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
101
+ val channel = android.app.NotificationChannel(
102
+ CHANNEL_ID,
103
+ "Music Playback",
104
+ android.app.NotificationManager.IMPORTANCE_HIGH
105
+ ).apply {
106
+ description = "Controls for music playback"
107
+ setShowBadge(false)
108
+ setSound(null, null)
109
+ }
110
+ val manager = getSystemService(android.app.NotificationManager::class.java)
111
+ manager.createNotificationChannel(channel)
112
+ }
113
+
114
+ // 2. Immediate Foregrounding with a safe system icon
115
+ val initialNotification = NotificationCompat.Builder(this, CHANNEL_ID)
116
+ .setSmallIcon(android.R.drawable.ic_media_play)
117
+ .setContentTitle("Gliph Player")
118
+ .setContentText("Preparing playback...")
119
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
120
+ .setOngoing(true)
121
+ .build()
122
+
123
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
124
+ startForeground(NOTIFICATION_ID, initialNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
125
+ } else {
126
+ startForeground(NOTIFICATION_ID, initialNotification)
127
+ }
128
+
129
+ // 3. Initialize player
130
+ initPlayer()
131
+ }
132
+
133
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
134
+ super.onStartCommand(intent, flags, startId)
135
+ when (intent?.action) {
136
+ ACTION_PLAY -> play()
137
+ ACTION_PAUSE -> pause()
138
+ ACTION_SKIP_NEXT -> skipToNext(-1.0)
139
+ ACTION_SKIP_PREVIOUS -> skipToPrevious(-1.0)
140
+ ACTION_STOP -> {
141
+ stop()
142
+ stopSelf()
143
+ }
144
+ }
145
+ return START_STICKY
146
+ }
147
+
148
+ override fun onDestroy() {
149
+ scope.cancel()
150
+ mediaSession.release()
151
+ player.release()
152
+ super.onDestroy()
153
+ }
154
+
155
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
156
+ Log.d("GliphPlayer", "onGetSession called from: ${controllerInfo.packageName}")
157
+ return mediaSession
158
+ }
159
+
160
+ override fun onTaskRemoved(rootIntent: Intent?) {
161
+ val behavior = options?.getMap("android")?.getString("appKilledPlaybackBehavior")
162
+ if (behavior == "StopPlaybackAndRemoveNotification") {
163
+ destroy()
164
+ stopSelf()
165
+ } else if (behavior == "PausePlayback") {
166
+ player.pause()
167
+ }
168
+ super.onTaskRemoved(rootIntent)
169
+ }
170
+
171
+ // ── Init ────────────────────────────────────────────────────────────────────
172
+
173
+ private fun initPlayer(opts: ReadableMap? = null) {
174
+ // ── Fix #4: Buffer values from JS are in SECONDS (Double).
175
+ // ExoPlayer's DefaultLoadControl expects MILLISECONDS (Long).
176
+ // We convert here so callers never accidentally buffer for 1000 seconds.
177
+ //
178
+ // JS API contract (all values in seconds):
179
+ // minBuffer — minimum seconds to buffer before playback starts (default 15s)
180
+ // maxBuffer — maximum seconds to buffer ahead (default 50s)
181
+ // playBuffer — seconds buffered before playback resumes after stall (default 2.5s)
182
+ // backBuffer — seconds of audio to keep behind current position (default 0s)
183
+ val minBufferMs = (getDouble(opts, "minBuffer", 15.0) * 1000).toInt()
184
+ val maxBufferMs = (getDouble(opts, "maxBuffer", 50.0) * 1000).toInt()
185
+ val playBufferMs = (getDouble(opts, "playBuffer", 2.5) * 1000).toInt()
186
+ val backBufferMs = (getDouble(opts, "backBuffer", 0.0) * 1000).toInt()
187
+
188
+ val loadControl = androidx.media3.exoplayer.DefaultLoadControl.Builder()
189
+ .setBufferDurationsMs(minBufferMs, maxBufferMs, playBufferMs, playBufferMs)
190
+ .setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe= */ false)
191
+ .build()
192
+
193
+ player = ExoPlayer.Builder(this)
194
+ .setMediaSourceFactory(DefaultMediaSourceFactory(this))
195
+ .setLoadControl(loadControl)
196
+ .setHandleAudioBecomingNoisy(true)
197
+ .setWakeMode(C.WAKE_MODE_NETWORK)
198
+ .setAudioAttributes(
199
+ AudioAttributes.Builder()
200
+ .setUsage(C.USAGE_MEDIA)
201
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
202
+ .build(),
203
+ /* handleAudioFocus= */ true
204
+ )
205
+ .build()
206
+
207
+ player.addListener(playerListener)
208
+
209
+ val sessionActivityIntent = packageManager
210
+ .getLaunchIntentForPackage(packageName)
211
+ ?.let { PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE) }
212
+
213
+ mediaSession = MediaLibrarySession.Builder(this, player, mediaSessionCallback)
214
+ .also { builder ->
215
+ sessionActivityIntent?.let { builder.setSessionActivity(it) }
216
+ }
217
+ .build()
218
+ }
219
+
220
+ private fun servicePendingIntent(action: String, requestCode: Int): PendingIntent {
221
+ val intent = Intent(this, GliphPlayerService::class.java).apply {
222
+ this.action = action
223
+ }
224
+ return PendingIntent.getService(
225
+ this,
226
+ requestCode,
227
+ intent,
228
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
229
+ )
230
+ }
231
+
232
+ private fun appPendingIntent(): PendingIntent? {
233
+ return packageManager
234
+ .getLaunchIntentForPackage(packageName)
235
+ ?.let { intent ->
236
+ PendingIntent.getActivity(
237
+ this,
238
+ 0,
239
+ intent,
240
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
241
+ )
242
+ }
243
+ }
244
+
245
+ private fun buildMediaNotification(): Notification {
246
+ val currentMediaItem = player.currentMediaItem
247
+ val metadata = currentMediaItem?.mediaMetadata
248
+ val playPauseIcon = if (player.isPlaying) {
249
+ android.R.drawable.ic_media_pause
250
+ } else {
251
+ android.R.drawable.ic_media_play
252
+ }
253
+ val playPauseTitle = if (player.isPlaying) "Pause" else "Play"
254
+ val playPauseAction = if (player.isPlaying) ACTION_PAUSE else ACTION_PLAY
255
+
256
+ return NotificationCompat.Builder(this, CHANNEL_ID)
257
+ .setSmallIcon(android.R.drawable.ic_media_play)
258
+ .setContentTitle(metadata?.title ?: "Gliph Player")
259
+ .setContentText(metadata?.artist ?: "Ready to play")
260
+ .setSubText(metadata?.albumTitle)
261
+ .setContentIntent(appPendingIntent())
262
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
263
+ .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
264
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
265
+ .setOnlyAlertOnce(true)
266
+ .setSilent(true)
267
+ .setOngoing(player.isPlaying)
268
+ .addAction(
269
+ android.R.drawable.ic_media_previous,
270
+ "Previous",
271
+ servicePendingIntent(ACTION_SKIP_PREVIOUS, 1)
272
+ )
273
+ .addAction(
274
+ playPauseIcon,
275
+ playPauseTitle,
276
+ servicePendingIntent(playPauseAction, 2)
277
+ )
278
+ .addAction(
279
+ android.R.drawable.ic_media_next,
280
+ "Next",
281
+ servicePendingIntent(ACTION_SKIP_NEXT, 3)
282
+ )
283
+ .setStyle(
284
+ androidx.media.app.NotificationCompat.MediaStyle()
285
+ .setMediaSession(mediaSession.sessionCompatToken)
286
+ .setShowActionsInCompactView(0, 1, 2)
287
+ )
288
+ .build()
289
+ }
290
+
291
+ private fun updateMediaNotification() {
292
+ if (!::player.isInitialized || !::mediaSession.isInitialized) {
293
+ return
294
+ }
295
+
296
+ val notification = buildMediaNotification()
297
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
298
+ startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
299
+ } else {
300
+ startForeground(NOTIFICATION_ID, notification)
301
+ }
302
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
303
+ manager.notify(NOTIFICATION_ID, notification)
304
+ }
305
+
306
+ fun setEventEmitter(emitter: (String, WritableMap?) -> Unit) {
307
+ eventEmitter = emitter
308
+ }
309
+
310
+ // ── Setup ───────────────────────────────────────────────────────────────────
311
+
312
+ fun setupPlayer(opts: ReadableMap) {
313
+ Log.d("GliphPlayer", "setupPlayer called with opts: $opts")
314
+ options = opts
315
+
316
+ // ── Fix #5: Android-specific options (appKilledPlaybackBehavior, audioUsage,
317
+ // audioContentType) are applied HERE at setup time, not in updateOptions.
318
+ // This avoids Codegen type mismatches and ensures the ExoPlayer AudioAttributes
319
+ // are configured before the first track is loaded.
320
+ val androidOpts = if (opts.hasKey("android")) opts.getMap("android") else null
321
+ val audioUsageStr = getString(androidOpts, "audioUsage", null)
322
+ val audioUsage = when (audioUsageStr) {
323
+ "voiceCommunication" -> C.USAGE_VOICE_COMMUNICATION
324
+ "alarm" -> C.USAGE_ALARM
325
+ "notification" -> C.USAGE_NOTIFICATION
326
+ "game" -> C.USAGE_GAME
327
+ else -> C.USAGE_MEDIA // default: music
328
+ }
329
+ val audioContentTypeStr = getString(androidOpts, "audioContentType", null)
330
+ val audioContentType = when (audioContentTypeStr) {
331
+ "speech" -> C.AUDIO_CONTENT_TYPE_SPEECH
332
+ "sonification" -> C.AUDIO_CONTENT_TYPE_SONIFICATION
333
+ "movie" -> C.AUDIO_CONTENT_TYPE_MOVIE
334
+ else -> C.AUDIO_CONTENT_TYPE_MUSIC // default
335
+ }
336
+
337
+ // Re-init player with the correct buffer settings and audio attributes
338
+ if (::player.isInitialized) {
339
+ player.release()
340
+ mediaSession.release()
341
+ }
342
+ initPlayer(opts)
343
+
344
+ // Apply audio attributes after init (ExoPlayer allows this before first play)
345
+ player.setAudioAttributes(
346
+ AudioAttributes.Builder()
347
+ .setUsage(audioUsage)
348
+ .setContentType(audioContentType)
349
+ .build(),
350
+ /* handleAudioFocus= */ true
351
+ )
352
+
353
+ startProgressUpdates()
354
+ }
355
+
356
+ fun destroy() {
357
+ stopProgressUpdates()
358
+ scope.launch(Dispatchers.Main) {
359
+ if (::player.isInitialized) {
360
+ player.stop()
361
+ player.clearMediaItems()
362
+ }
363
+ synchronized(queue) {
364
+ queue.clear()
365
+ }
366
+ }
367
+ }
368
+
369
+ // ── Queue management ────────────────────────────────────────────────────────
370
+
371
+ fun add(tracks: ReadableArray, insertBeforeIndex: Int): Int {
372
+ val startIndex = if (insertBeforeIndex < 0 || insertBeforeIndex > queue.size) {
373
+ queue.size
374
+ } else {
375
+ insertBeforeIndex
376
+ }
377
+
378
+ val mediaItems = mutableListOf<MediaItem>()
379
+ for (i in 0 until tracks.size()) {
380
+ val track = tracks.getMap(i) ?: continue
381
+ Log.d("GliphPlayer", "Adding track: ${track.getString("title")} (URL: ${track.getString("url")})")
382
+ queue.add(startIndex + (i), track)
383
+ mediaItems.add(buildMediaItem(track))
384
+ }
385
+
386
+ if (insertBeforeIndex < 0 || insertBeforeIndex >= player.mediaItemCount) {
387
+ player.addMediaItems(mediaItems)
388
+ } else {
389
+ player.addMediaItems(insertBeforeIndex, mediaItems)
390
+ }
391
+
392
+ updateMediaNotification()
393
+ return startIndex
394
+ }
395
+
396
+ fun remove(trackIds: ReadableArray) {
397
+ val ids = (0 until trackIds.size()).map { trackIds.getString(it) ?: "" }.toSet()
398
+ val indicesToRemove = queue.indices
399
+ .filter { i -> queue[i].getString("id") in ids }
400
+ .sortedDescending()
401
+
402
+ for (i in indicesToRemove) {
403
+ queue.removeAt(i)
404
+ player.removeMediaItem(i)
405
+ }
406
+ }
407
+
408
+ fun removeUpcomingTracks() {
409
+ val current = player.currentMediaItemIndex
410
+ val total = player.mediaItemCount
411
+ if (current < total - 1) {
412
+ player.removeMediaItems(current + 1, total)
413
+ while (queue.size > current + 1) {
414
+ queue.removeAt(queue.size - 1)
415
+ }
416
+ }
417
+ }
418
+
419
+ fun skip(index: Int, initialPosition: Double) {
420
+ player.seekTo(index, if (initialPosition >= 0) (initialPosition * 1000).toLong() else C.TIME_UNSET)
421
+ player.play()
422
+ }
423
+
424
+ fun skipToNext(initialPosition: Double) {
425
+ if (player.hasNextMediaItem()) {
426
+ player.seekToNextMediaItem()
427
+ if (initialPosition >= 0) player.seekTo((initialPosition * 1000).toLong())
428
+ player.play()
429
+ }
430
+ }
431
+
432
+ fun skipToPrevious(initialPosition: Double) {
433
+ if (player.hasPreviousMediaItem()) {
434
+ player.seekToPreviousMediaItem()
435
+ if (initialPosition >= 0) player.seekTo((initialPosition * 1000).toLong())
436
+ player.play()
437
+ }
438
+ }
439
+
440
+ fun move(fromIndex: Int, toIndex: Int) {
441
+ if (fromIndex < 0 || fromIndex >= queue.size || toIndex < 0 || toIndex >= queue.size) return
442
+ val track = queue.removeAt(fromIndex)
443
+ queue.add(toIndex, track)
444
+ player.moveMediaItem(fromIndex, toIndex)
445
+ }
446
+
447
+ // ── Playback control ────────────────────────────────────────────────────────
448
+
449
+ fun play() {
450
+ if (player.playbackState == Player.STATE_IDLE) {
451
+ player.prepare()
452
+ }
453
+ player.play()
454
+ updateMediaNotification()
455
+ }
456
+
457
+ fun pause() {
458
+ player.pause()
459
+ updateMediaNotification()
460
+ }
461
+
462
+ fun stop() {
463
+ player.stop()
464
+ updateMediaNotification()
465
+ }
466
+
467
+ fun reset() {
468
+ player.stop()
469
+ player.clearMediaItems()
470
+ queue.clear()
471
+ updateMediaNotification()
472
+ }
473
+
474
+ fun seekTo(positionSeconds: Double) {
475
+ player.seekTo((positionSeconds * 1000).toLong())
476
+ val map = Arguments.createMap()
477
+ map.putDouble("position", positionSeconds)
478
+ map.putDouble("duration", if (player.duration == C.TIME_UNSET) 0.0 else player.duration / 1000.0)
479
+ map.putDouble("buffered", player.bufferedPosition / 1000.0)
480
+ map.putInt("track", player.currentMediaItemIndex)
481
+ eventEmitter?.invoke("playback-progress-updated", map)
482
+ }
483
+
484
+ fun seekBy(offsetSeconds: Double) {
485
+ val newPos = player.currentPosition + (offsetSeconds * 1000).toLong()
486
+ player.seekTo(newPos.coerceAtLeast(0))
487
+ }
488
+
489
+ fun setVolume(volume: Float) {
490
+ player.volume = volume
491
+ }
492
+
493
+ fun getVolume(): Float = player.volume
494
+
495
+ fun setRate(rate: Float) {
496
+ player.setPlaybackSpeed(rate)
497
+ }
498
+
499
+ fun getRate(): Float = player.playbackParameters.speed
500
+
501
+ fun setRepeatMode(mode: Int) {
502
+ player.repeatMode = when (mode) {
503
+ 1 -> Player.REPEAT_MODE_ONE
504
+ 2 -> Player.REPEAT_MODE_ALL
505
+ else -> Player.REPEAT_MODE_OFF
506
+ }
507
+ val map = Arguments.createMap()
508
+ map.putInt("mode", mode)
509
+ eventEmitter?.invoke("playback-repeat-mode-changed", map)
510
+ }
511
+
512
+ fun getRepeatMode(): Int = when (player.repeatMode) {
513
+ Player.REPEAT_MODE_ONE -> 1
514
+ Player.REPEAT_MODE_ALL -> 2
515
+ else -> 0
516
+ }
517
+
518
+ // ── Queue getters ───────────────────────────────────────────────────────────
519
+
520
+ fun getQueue(): WritableArray {
521
+ val arr = Arguments.createArray()
522
+ queue.forEach { track ->
523
+ arr.pushMap(Arguments.makeNativeMap(track.toHashMap()))
524
+ }
525
+ return arr
526
+ }
527
+
528
+ fun getActiveTrackIndex(): Int {
529
+ val idx = player.currentMediaItemIndex
530
+ return if (idx >= 0 && idx < queue.size) idx else -1
531
+ }
532
+
533
+ fun getActiveTrack(): WritableMap? {
534
+ val idx = getActiveTrackIndex()
535
+ if (idx < 0 || idx >= queue.size) return null
536
+ return Arguments.makeNativeMap(queue[idx].toHashMap())
537
+ }
538
+
539
+ fun getTrack(index: Int): WritableMap? {
540
+ if (index < 0 || index >= queue.size) return null
541
+ return Arguments.makeNativeMap(queue[index].toHashMap())
542
+ }
543
+
544
+ fun getQueueSize(): Int = queue.size
545
+
546
+ // ── State / progress ────────────────────────────────────────────────────────
547
+
548
+ fun getPlaybackState(): WritableMap {
549
+ val map = Arguments.createMap()
550
+ map.putString("state", mapPlayerState())
551
+ return map
552
+ }
553
+
554
+ fun getProgress(): WritableMap {
555
+ val map = Arguments.createMap()
556
+ map.putDouble("position", player.currentPosition / 1000.0)
557
+ map.putDouble("duration", if (player.duration == C.TIME_UNSET) 0.0 else player.duration / 1000.0)
558
+ map.putDouble("buffered", player.bufferedPosition / 1000.0)
559
+ return map
560
+ }
561
+
562
+ private fun mapPlayerState(): String {
563
+ if (player.playerError != null) return "error"
564
+ if (!player.playWhenReady && player.playbackState == Player.STATE_READY) return "paused"
565
+ return when (player.playbackState) {
566
+ Player.STATE_IDLE -> "none"
567
+ Player.STATE_BUFFERING -> if (player.playWhenReady) "buffering" else "loading"
568
+ Player.STATE_READY -> if (player.isPlaying) "playing" else "paused"
569
+ Player.STATE_ENDED -> "ended"
570
+ else -> "none"
571
+ }
572
+ }
573
+
574
+ // ── Metadata ────────────────────────────────────────────────────────────────
575
+
576
+ fun updateMetadataForTrack(index: Int, metadata: ReadableMap) {
577
+ if (index < 0 || index >= queue.size) return
578
+ val existing = queue[index].toHashMap()
579
+ metadata.toHashMap().forEach { (k, v) -> existing[k] = v }
580
+ // Rebuild the queue entry (ReadableMap is immutable, so we use a WritableMap)
581
+ val updated = Arguments.makeNativeMap(existing)
582
+ queue[index] = updated
583
+ // Update ExoPlayer media item metadata
584
+ player.replaceMediaItem(index, buildMediaItem(updated))
585
+ updateMediaNotification()
586
+ }
587
+
588
+ fun clearNowPlayingMetadata() {
589
+ mediaSession.setCustomLayout(emptyList())
590
+ }
591
+
592
+ fun updateNowPlayingMetadata(metadata: ReadableMap) {
593
+ val currentIndex = player.currentMediaItemIndex
594
+ if (currentIndex < 0 || currentIndex >= queue.size) return
595
+
596
+ val existing = queue[currentIndex].toHashMap()
597
+ metadata.toHashMap().forEach { (k, v) -> existing[k] = v }
598
+ val updated = Arguments.makeNativeMap(existing)
599
+ queue[currentIndex] = updated
600
+
601
+ // Update the live MediaItem in the player
602
+ val currentItem = player.currentMediaItem ?: return
603
+ val newMetadata = currentItem.mediaMetadata.buildUpon()
604
+ .also { builder ->
605
+ if (metadata.hasKey("title")) builder.setTitle(metadata.getString("title"))
606
+ if (metadata.hasKey("artist")) builder.setArtist(metadata.getString("artist"))
607
+ if (metadata.hasKey("album")) builder.setAlbumTitle(metadata.getString("album"))
608
+ if (metadata.hasKey("artwork")) builder.setArtworkUri(android.net.Uri.parse(metadata.getString("artwork")))
609
+ }
610
+ .build()
611
+
612
+ player.replaceMediaItem(currentIndex, currentItem.buildUpon().setMediaMetadata(newMetadata).build())
613
+ updateMediaNotification()
614
+ }
615
+
616
+ fun updateOptions(opts: ReadableMap) {
617
+ // Merge new options with existing ones to avoid wiping out buffer settings
618
+ val merged = Arguments.createMap()
619
+ options?.let { merged.merge(it) }
620
+ merged.merge(opts)
621
+ options = merged
622
+
623
+ // Restart progress updates if the interval changed
624
+ if (opts.hasKey("progressUpdateEventInterval")) {
625
+ startProgressUpdates()
626
+ }
627
+ }
628
+
629
+ // ── Progress updates ────────────────────────────────────────────────────────
630
+
631
+ private fun startProgressUpdates() {
632
+ stopProgressUpdates()
633
+ val interval = (getDouble(options, "progressUpdateEventInterval", 1.0) * 1000).toLong()
634
+ progressJob = scope.launch {
635
+ while (isActive) {
636
+ delay(interval)
637
+ if (player.isPlaying) {
638
+ val map = Arguments.createMap()
639
+ map.putDouble("position", player.currentPosition / 1000.0)
640
+ map.putDouble("duration", if (player.duration == C.TIME_UNSET) 0.0 else player.duration / 1000.0)
641
+ map.putDouble("buffered", player.bufferedPosition / 1000.0)
642
+ map.putInt("track", player.currentMediaItemIndex)
643
+ eventEmitter?.invoke("playback-progress-updated", map)
644
+ }
645
+ }
646
+ }
647
+ }
648
+
649
+ private fun stopProgressUpdates() {
650
+ progressJob?.cancel()
651
+ progressJob = null
652
+ }
653
+
654
+ // ── MediaItem builder ───────────────────────────────────────────────────────
655
+
656
+ private fun buildMediaItem(track: ReadableMap): MediaItem {
657
+ val url = track.getString("url") ?: ""
658
+ val title = track.getString("title") ?: ""
659
+ val artist = track.getString("artist") ?: ""
660
+ val album = track.getString("album") ?: ""
661
+ val artworkUri = track.getString("artwork")
662
+
663
+ val metadata = MediaMetadata.Builder()
664
+ .setTitle(title)
665
+ .setArtist(artist)
666
+ .setAlbumTitle(album)
667
+ .also { builder ->
668
+ artworkUri?.takeIf { it.isNotEmpty() }?.let {
669
+ builder.setArtworkUri(android.net.Uri.parse(it))
670
+ }
671
+ }
672
+ .build()
673
+
674
+ return MediaItem.Builder()
675
+ .setUri(url)
676
+ .setMediaId(track.getString("id") ?: url)
677
+ .setMediaMetadata(metadata)
678
+ .also { builder ->
679
+ // Custom headers
680
+ val headers = track.getMap("headers")
681
+ if (headers != null) {
682
+ val headersMap = headers.toHashMap().mapValues { it.value.toString() }
683
+ if (headersMap.isNotEmpty()) {
684
+ builder.setRequestMetadata(
685
+ MediaItem.RequestMetadata.Builder()
686
+ .setExtras(android.os.Bundle().apply {
687
+ headersMap.forEach { (k, v) -> putString(k, v) }
688
+ })
689
+ .build()
690
+ )
691
+ }
692
+ }
693
+ }
694
+ .build()
695
+ }
696
+
697
+ // ── Player listener ─────────────────────────────────────────────────────────
698
+
699
+ private val playerListener = object : Player.Listener {
700
+
701
+ private var lastIndex = -1
702
+
703
+ override fun onPlaybackStateChanged(playbackState: Int) {
704
+ emitPlaybackState()
705
+ updateMediaNotification()
706
+ if (playbackState == Player.STATE_ENDED) {
707
+ // Emit track ended for the last item in the queue if it just finished
708
+ val map = Arguments.createMap()
709
+ map.putInt("index", player.currentMediaItemIndex)
710
+ eventEmitter?.invoke("playback-track-ended", map)
711
+
712
+ // Then emit queue ended
713
+ val queueMap = Arguments.createMap()
714
+ queueMap.putInt("index", player.currentMediaItemIndex)
715
+ queueMap.putDouble("position", player.currentPosition / 1000.0)
716
+ eventEmitter?.invoke("playback-queue-ended", queueMap)
717
+ }
718
+ }
719
+
720
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
721
+ emitPlaybackState()
722
+ updateMediaNotification()
723
+ }
724
+
725
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
726
+ // If we transitioned to a new item, the previous one "ended" its playback
727
+ if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) {
728
+ val endedMap = Arguments.createMap()
729
+ endedMap.putInt("index", lastIndex)
730
+ eventEmitter?.invoke("playback-track-ended", endedMap)
731
+ }
732
+
733
+ val newIndex = player.currentMediaItemIndex
734
+ val map = Arguments.createMap()
735
+ map.putInt("index", newIndex)
736
+ map.putInt("lastIndex", lastIndex)
737
+ map.putDouble("lastPosition", player.currentPosition / 1000.0)
738
+
739
+ if (newIndex >= 0 && newIndex < queue.size) {
740
+ map.putMap("track", Arguments.makeNativeMap(queue[newIndex].toHashMap()))
741
+ } else {
742
+ map.putNull("track")
743
+ }
744
+
745
+ if (lastIndex >= 0 && lastIndex < queue.size) {
746
+ map.putMap("lastTrack", Arguments.makeNativeMap(queue[lastIndex].toHashMap()))
747
+ } else {
748
+ map.putNull("lastTrack")
749
+ }
750
+
751
+ eventEmitter?.invoke("playback-active-track-changed", map)
752
+ lastIndex = newIndex
753
+ updateMediaNotification()
754
+ }
755
+
756
+ override fun onPlayerError(error: PlaybackException) {
757
+ val map = Arguments.createMap()
758
+ map.putString("code", "playback_error_${error.errorCode}")
759
+ map.putString("message", error.message ?: "Unknown playback error")
760
+ eventEmitter?.invoke("playback-error", map)
761
+ emitPlaybackState()
762
+
763
+ // Optional auto-skip recovery
764
+ val autoSkip = options?.getMap("android")?.let { androidMap ->
765
+ androidMap.hasKey("autoSkipOnError") && androidMap.getBoolean("autoSkipOnError")
766
+ } ?: false
767
+ if (autoSkip && player.hasNextMediaItem()) {
768
+ player.seekToNextMediaItem()
769
+ player.prepare()
770
+ player.play()
771
+ }
772
+ }
773
+
774
+ private fun emitPlaybackState() {
775
+ val map = Arguments.createMap()
776
+ map.putString("state", mapPlayerState())
777
+ eventEmitter?.invoke("playback-state", map)
778
+ }
779
+ }
780
+
781
+ // ── MediaSession callback ───────────────────────────────────────────────────
782
+
783
+ private val mediaSessionCallback = object : MediaLibrarySession.Callback {
784
+
785
+ override fun onAddMediaItems(
786
+ mediaSession: MediaSession,
787
+ controller: MediaSession.ControllerInfo,
788
+ mediaItems: MutableList<MediaItem>
789
+ ): com.google.common.util.concurrent.ListenableFuture<MutableList<MediaItem>> {
790
+ // Resolve URIs for Android Auto / external controllers
791
+ val resolved = mediaItems.map { item ->
792
+ item.buildUpon().setUri(item.requestMetadata.mediaUri).build()
793
+ }.toMutableList()
794
+ return com.google.common.util.concurrent.Futures.immediateFuture(resolved)
795
+ }
796
+ }
797
+ }