expo-gliph-player 1.3.13 → 1.3.14
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.
|
@@ -52,16 +52,12 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
52
52
|
private val binder = LocalBinder()
|
|
53
53
|
|
|
54
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
55
|
val action = intent?.action
|
|
59
56
|
if (action == null ||
|
|
60
57
|
action == "com.gliphplayer.BIND_LOCAL" ||
|
|
61
58
|
action == Intent.ACTION_MAIN) {
|
|
62
59
|
return binder
|
|
63
60
|
}
|
|
64
|
-
// Let MediaLibraryService handle MediaBrowser / MediaSession connections
|
|
65
61
|
return super.onBind(intent)
|
|
66
62
|
}
|
|
67
63
|
|
|
@@ -75,7 +71,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
75
71
|
private var progressJob: Job? = null
|
|
76
72
|
private var options: ReadableMap? = null
|
|
77
73
|
|
|
78
|
-
// Internal queue (mirrors ExoPlayer's playlist)
|
|
79
74
|
private val queue = java.util.Collections.synchronizedList(mutableListOf<ReadableMap>())
|
|
80
75
|
|
|
81
76
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -96,7 +91,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
96
91
|
|
|
97
92
|
override fun onCreate() {
|
|
98
93
|
super.onCreate()
|
|
99
|
-
// 1. Create high-importance notification channel
|
|
100
94
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
|
101
95
|
val channel = android.app.NotificationChannel(
|
|
102
96
|
CHANNEL_ID,
|
|
@@ -111,7 +105,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
111
105
|
manager.createNotificationChannel(channel)
|
|
112
106
|
}
|
|
113
107
|
|
|
114
|
-
// 2. Immediate Foregrounding with a safe system icon
|
|
115
108
|
val initialNotification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
116
109
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
117
110
|
.setContentTitle("Gliph Player")
|
|
@@ -126,7 +119,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
126
119
|
startForeground(NOTIFICATION_ID, initialNotification)
|
|
127
120
|
}
|
|
128
121
|
|
|
129
|
-
// 3. Initialize player
|
|
130
122
|
initPlayer()
|
|
131
123
|
}
|
|
132
124
|
|
|
@@ -171,15 +163,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
171
163
|
// ── Init ────────────────────────────────────────────────────────────────────
|
|
172
164
|
|
|
173
165
|
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
166
|
val minBufferMs = (getDouble(opts, "minBuffer", 15.0) * 1000).toInt()
|
|
184
167
|
val maxBufferMs = (getDouble(opts, "maxBuffer", 50.0) * 1000).toInt()
|
|
185
168
|
val playBufferMs = (getDouble(opts, "playBuffer", 2.5) * 1000).toInt()
|
|
@@ -313,10 +296,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
313
296
|
Log.d("GliphPlayer", "setupPlayer called with opts: $opts")
|
|
314
297
|
options = opts
|
|
315
298
|
|
|
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
299
|
val androidOpts = if (opts.hasKey("android")) opts.getMap("android") else null
|
|
321
300
|
val audioUsageStr = getString(androidOpts, "audioUsage", null)
|
|
322
301
|
val audioUsage = when (audioUsageStr) {
|
|
@@ -324,24 +303,22 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
324
303
|
"alarm" -> C.USAGE_ALARM
|
|
325
304
|
"notification" -> C.USAGE_NOTIFICATION
|
|
326
305
|
"game" -> C.USAGE_GAME
|
|
327
|
-
else -> C.USAGE_MEDIA
|
|
306
|
+
else -> C.USAGE_MEDIA
|
|
328
307
|
}
|
|
329
308
|
val audioContentTypeStr = getString(androidOpts, "audioContentType", null)
|
|
330
309
|
val audioContentType = when (audioContentTypeStr) {
|
|
331
310
|
"speech" -> C.AUDIO_CONTENT_TYPE_SPEECH
|
|
332
311
|
"sonification" -> C.AUDIO_CONTENT_TYPE_SONIFICATION
|
|
333
312
|
"movie" -> C.AUDIO_CONTENT_TYPE_MOVIE
|
|
334
|
-
else -> C.AUDIO_CONTENT_TYPE_MUSIC
|
|
313
|
+
else -> C.AUDIO_CONTENT_TYPE_MUSIC
|
|
335
314
|
}
|
|
336
315
|
|
|
337
|
-
// Re-init player with the correct buffer settings and audio attributes
|
|
338
316
|
if (::player.isInitialized) {
|
|
339
317
|
player.release()
|
|
340
318
|
mediaSession.release()
|
|
341
319
|
}
|
|
342
320
|
initPlayer(opts)
|
|
343
321
|
|
|
344
|
-
// Apply audio attributes after init (ExoPlayer allows this before first play)
|
|
345
322
|
player.setAudioAttributes(
|
|
346
323
|
AudioAttributes.Builder()
|
|
347
324
|
.setUsage(audioUsage)
|
|
@@ -366,6 +343,38 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
366
343
|
}
|
|
367
344
|
}
|
|
368
345
|
|
|
346
|
+
// ── 🆕 HELPER: Отправка события об изменении активного трека ──────────────
|
|
347
|
+
|
|
348
|
+
private fun emitActiveTrackChanged(lastIndex: Int = -1) {
|
|
349
|
+
val newIndex = player.currentMediaItemIndex
|
|
350
|
+
val map = Arguments.createMap()
|
|
351
|
+
map.putInt("index", newIndex)
|
|
352
|
+
map.putInt("lastIndex", if (lastIndex >= 0) lastIndex else newIndex)
|
|
353
|
+
map.putDouble("lastPosition", player.currentPosition / 1000.0)
|
|
354
|
+
|
|
355
|
+
if (newIndex >= 0 && newIndex < queue.size) {
|
|
356
|
+
map.putMap("track", Arguments.makeNativeMap(queue[newIndex].toHashMap()))
|
|
357
|
+
} else {
|
|
358
|
+
map.putNull("track")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (lastIndex >= 0 && lastIndex < queue.size) {
|
|
362
|
+
map.putMap("lastTrack", Arguments.makeNativeMap(queue[lastIndex].toHashMap()))
|
|
363
|
+
} else {
|
|
364
|
+
map.putNull("lastTrack")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
eventEmitter?.invoke("playback-active-track-changed", map)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── 🆕 HELPER: Отправка состояния ──────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
private fun emitPlaybackState(state: String) {
|
|
373
|
+
val map = Arguments.createMap()
|
|
374
|
+
map.putString("state", state)
|
|
375
|
+
eventEmitter?.invoke("playback-state", map)
|
|
376
|
+
}
|
|
377
|
+
|
|
369
378
|
// ── Queue management ────────────────────────────────────────────────────────
|
|
370
379
|
|
|
371
380
|
fun add(tracks: ReadableArray, insertBeforeIndex: Int): Int {
|
|
@@ -375,11 +384,13 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
375
384
|
insertBeforeIndex
|
|
376
385
|
}
|
|
377
386
|
|
|
387
|
+
val wasEmpty = queue.isEmpty() // ✅ Сохраняем состояние ДО добавления
|
|
378
388
|
val mediaItems = mutableListOf<MediaItem>()
|
|
389
|
+
|
|
379
390
|
for (i in 0 until tracks.size()) {
|
|
380
391
|
val track = tracks.getMap(i) ?: continue
|
|
381
392
|
Log.d("GliphPlayer", "Adding track: ${track.getString("title")} (URL: ${track.getString("url")})")
|
|
382
|
-
queue.add(startIndex +
|
|
393
|
+
queue.add(startIndex + i, track)
|
|
383
394
|
mediaItems.add(buildMediaItem(track))
|
|
384
395
|
}
|
|
385
396
|
|
|
@@ -389,6 +400,11 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
389
400
|
player.addMediaItems(insertBeforeIndex, mediaItems)
|
|
390
401
|
}
|
|
391
402
|
|
|
403
|
+
// ✅ Если очередь была пуста - отправляем событие
|
|
404
|
+
if (wasEmpty && queue.isNotEmpty()) {
|
|
405
|
+
emitActiveTrackChanged()
|
|
406
|
+
}
|
|
407
|
+
|
|
392
408
|
updateMediaNotification()
|
|
393
409
|
return startIndex
|
|
394
410
|
}
|
|
@@ -416,24 +432,42 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
416
432
|
}
|
|
417
433
|
}
|
|
418
434
|
|
|
435
|
+
// ✅ ИСПРАВЛЕНО: Добавлено событие
|
|
419
436
|
fun skip(index: Int, initialPosition: Double) {
|
|
437
|
+
val lastIndex = player.currentMediaItemIndex
|
|
420
438
|
player.seekTo(index, if (initialPosition >= 0) (initialPosition * 1000).toLong() else C.TIME_UNSET)
|
|
421
439
|
player.play()
|
|
440
|
+
|
|
441
|
+
// ✅ Отправляем событие
|
|
442
|
+
emitActiveTrackChanged(lastIndex)
|
|
443
|
+
emitPlaybackState("playing")
|
|
422
444
|
}
|
|
423
445
|
|
|
446
|
+
// ✅ ИСПРАВЛЕНО: Добавлено событие
|
|
424
447
|
fun skipToNext(initialPosition: Double) {
|
|
425
448
|
if (player.hasNextMediaItem()) {
|
|
449
|
+
val lastIndex = player.currentMediaItemIndex
|
|
426
450
|
player.seekToNextMediaItem()
|
|
427
451
|
if (initialPosition >= 0) player.seekTo((initialPosition * 1000).toLong())
|
|
428
452
|
player.play()
|
|
453
|
+
|
|
454
|
+
// ✅ Отправляем событие
|
|
455
|
+
emitActiveTrackChanged(lastIndex)
|
|
456
|
+
emitPlaybackState("playing")
|
|
429
457
|
}
|
|
430
458
|
}
|
|
431
459
|
|
|
460
|
+
// ✅ ИСПРАВЛЕНО: Добавлено событие
|
|
432
461
|
fun skipToPrevious(initialPosition: Double) {
|
|
433
462
|
if (player.hasPreviousMediaItem()) {
|
|
463
|
+
val lastIndex = player.currentMediaItemIndex
|
|
434
464
|
player.seekToPreviousMediaItem()
|
|
435
465
|
if (initialPosition >= 0) player.seekTo((initialPosition * 1000).toLong())
|
|
436
466
|
player.play()
|
|
467
|
+
|
|
468
|
+
// ✅ Отправляем событие
|
|
469
|
+
emitActiveTrackChanged(lastIndex)
|
|
470
|
+
emitPlaybackState("playing")
|
|
437
471
|
}
|
|
438
472
|
}
|
|
439
473
|
|
|
@@ -446,28 +480,61 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
446
480
|
|
|
447
481
|
// ── Playback control ────────────────────────────────────────────────────────
|
|
448
482
|
|
|
483
|
+
// ✅ ИСПРАВЛЕНО: Добавлены события
|
|
449
484
|
fun play() {
|
|
450
485
|
if (player.playbackState == Player.STATE_IDLE) {
|
|
451
486
|
player.prepare()
|
|
452
487
|
}
|
|
453
488
|
player.play()
|
|
489
|
+
|
|
490
|
+
// ✅ Отправляем состояние
|
|
491
|
+
emitPlaybackState("playing")
|
|
492
|
+
|
|
493
|
+
// ✅ Если есть активный трек - отправляем его
|
|
494
|
+
if (player.currentMediaItemIndex >= 0 && player.currentMediaItemIndex < queue.size) {
|
|
495
|
+
emitActiveTrackChanged()
|
|
496
|
+
}
|
|
497
|
+
|
|
454
498
|
updateMediaNotification()
|
|
455
499
|
}
|
|
456
500
|
|
|
501
|
+
// ✅ ИСПРАВЛЕНО: Добавлено событие
|
|
457
502
|
fun pause() {
|
|
458
503
|
player.pause()
|
|
504
|
+
|
|
505
|
+
// ✅ Отправляем состояние
|
|
506
|
+
emitPlaybackState("paused")
|
|
507
|
+
|
|
459
508
|
updateMediaNotification()
|
|
460
509
|
}
|
|
461
510
|
|
|
511
|
+
// ✅ ИСПРАВЛЕНО: Добавлено событие
|
|
462
512
|
fun stop() {
|
|
463
513
|
player.stop()
|
|
514
|
+
|
|
515
|
+
// ✅ Отправляем состояние
|
|
516
|
+
emitPlaybackState("stopped")
|
|
517
|
+
|
|
464
518
|
updateMediaNotification()
|
|
465
519
|
}
|
|
466
520
|
|
|
521
|
+
// ✅ ИСПРАВЛЕНО: Добавлены события
|
|
467
522
|
fun reset() {
|
|
523
|
+
val lastIndex = player.currentMediaItemIndex
|
|
468
524
|
player.stop()
|
|
469
525
|
player.clearMediaItems()
|
|
470
526
|
queue.clear()
|
|
527
|
+
|
|
528
|
+
// ✅ Отправляем событие об очистке
|
|
529
|
+
val map = Arguments.createMap()
|
|
530
|
+
map.putInt("index", -1)
|
|
531
|
+
map.putNull("track")
|
|
532
|
+
map.putInt("lastIndex", lastIndex)
|
|
533
|
+
map.putDouble("lastPosition", 0.0)
|
|
534
|
+
eventEmitter?.invoke("playback-active-track-changed", map)
|
|
535
|
+
|
|
536
|
+
emitPlaybackState("none")
|
|
537
|
+
|
|
471
538
|
updateMediaNotification()
|
|
472
539
|
}
|
|
473
540
|
|
|
@@ -577,10 +644,8 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
577
644
|
if (index < 0 || index >= queue.size) return
|
|
578
645
|
val existing = queue[index].toHashMap()
|
|
579
646
|
metadata.toHashMap().forEach { (k, v) -> existing[k] = v }
|
|
580
|
-
// Rebuild the queue entry (ReadableMap is immutable, so we use a WritableMap)
|
|
581
647
|
val updated = Arguments.makeNativeMap(existing)
|
|
582
648
|
queue[index] = updated
|
|
583
|
-
// Update ExoPlayer media item metadata
|
|
584
649
|
player.replaceMediaItem(index, buildMediaItem(updated))
|
|
585
650
|
updateMediaNotification()
|
|
586
651
|
}
|
|
@@ -598,7 +663,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
598
663
|
val updated = Arguments.makeNativeMap(existing)
|
|
599
664
|
queue[currentIndex] = updated
|
|
600
665
|
|
|
601
|
-
// Update the live MediaItem in the player
|
|
602
666
|
val currentItem = player.currentMediaItem ?: return
|
|
603
667
|
val newMetadata = currentItem.mediaMetadata.buildUpon()
|
|
604
668
|
.also { builder ->
|
|
@@ -614,13 +678,11 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
614
678
|
}
|
|
615
679
|
|
|
616
680
|
fun updateOptions(opts: ReadableMap) {
|
|
617
|
-
// Merge new options with existing ones to avoid wiping out buffer settings
|
|
618
681
|
val merged = Arguments.createMap()
|
|
619
682
|
options?.let { merged.merge(it) }
|
|
620
683
|
merged.merge(opts)
|
|
621
684
|
options = merged
|
|
622
685
|
|
|
623
|
-
// Restart progress updates if the interval changed
|
|
624
686
|
if (opts.hasKey("progressUpdateEventInterval")) {
|
|
625
687
|
startProgressUpdates()
|
|
626
688
|
}
|
|
@@ -676,7 +738,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
676
738
|
.setMediaId(track.getString("id") ?: url)
|
|
677
739
|
.setMediaMetadata(metadata)
|
|
678
740
|
.also { builder ->
|
|
679
|
-
// Custom headers
|
|
680
741
|
val headers = track.getMap("headers")
|
|
681
742
|
if (headers != null) {
|
|
682
743
|
val headersMap = headers.toHashMap().mapValues { it.value.toString() }
|
|
@@ -701,15 +762,16 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
701
762
|
private var lastIndex = -1
|
|
702
763
|
|
|
703
764
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
704
|
-
emitPlaybackState()
|
|
765
|
+
emitPlaybackState(mapPlayerState())
|
|
705
766
|
updateMediaNotification()
|
|
767
|
+
|
|
706
768
|
if (playbackState == Player.STATE_ENDED) {
|
|
707
|
-
//
|
|
769
|
+
// ✅ Отправляем track-ended
|
|
708
770
|
val map = Arguments.createMap()
|
|
709
771
|
map.putInt("index", player.currentMediaItemIndex)
|
|
710
772
|
eventEmitter?.invoke("playback-track-ended", map)
|
|
711
773
|
|
|
712
|
-
//
|
|
774
|
+
// ✅ Отправляем queue-ended
|
|
713
775
|
val queueMap = Arguments.createMap()
|
|
714
776
|
queueMap.putInt("index", player.currentMediaItemIndex)
|
|
715
777
|
queueMap.putDouble("position", player.currentPosition / 1000.0)
|
|
@@ -718,13 +780,14 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
718
780
|
}
|
|
719
781
|
|
|
720
782
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
721
|
-
emitPlaybackState()
|
|
783
|
+
emitPlaybackState(mapPlayerState())
|
|
722
784
|
updateMediaNotification()
|
|
723
785
|
}
|
|
724
786
|
|
|
725
787
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
726
|
-
//
|
|
727
|
-
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
|
|
788
|
+
// ✅ Отправляем track-ended для предыдущего трека
|
|
789
|
+
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ||
|
|
790
|
+
reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) {
|
|
728
791
|
val endedMap = Arguments.createMap()
|
|
729
792
|
endedMap.putInt("index", lastIndex)
|
|
730
793
|
eventEmitter?.invoke("playback-track-ended", endedMap)
|
|
@@ -748,6 +811,7 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
748
811
|
map.putNull("lastTrack")
|
|
749
812
|
}
|
|
750
813
|
|
|
814
|
+
// ✅ Отправляем active-track-changed
|
|
751
815
|
eventEmitter?.invoke("playback-active-track-changed", map)
|
|
752
816
|
lastIndex = newIndex
|
|
753
817
|
updateMediaNotification()
|
|
@@ -758,9 +822,8 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
758
822
|
map.putString("code", "playback_error_${error.errorCode}")
|
|
759
823
|
map.putString("message", error.message ?: "Unknown playback error")
|
|
760
824
|
eventEmitter?.invoke("playback-error", map)
|
|
761
|
-
emitPlaybackState()
|
|
825
|
+
emitPlaybackState("error")
|
|
762
826
|
|
|
763
|
-
// Optional auto-skip recovery
|
|
764
827
|
val autoSkip = options?.getMap("android")?.let { androidMap ->
|
|
765
828
|
androidMap.hasKey("autoSkipOnError") && androidMap.getBoolean("autoSkipOnError")
|
|
766
829
|
} ?: false
|
|
@@ -770,12 +833,6 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
770
833
|
player.play()
|
|
771
834
|
}
|
|
772
835
|
}
|
|
773
|
-
|
|
774
|
-
private fun emitPlaybackState() {
|
|
775
|
-
val map = Arguments.createMap()
|
|
776
|
-
map.putString("state", mapPlayerState())
|
|
777
|
-
eventEmitter?.invoke("playback-state", map)
|
|
778
|
-
}
|
|
779
836
|
}
|
|
780
837
|
|
|
781
838
|
// ── MediaSession callback ───────────────────────────────────────────────────
|
|
@@ -787,11 +844,10 @@ class GliphPlayerService : MediaLibraryService() {
|
|
|
787
844
|
controller: MediaSession.ControllerInfo,
|
|
788
845
|
mediaItems: MutableList<MediaItem>
|
|
789
846
|
): com.google.common.util.concurrent.ListenableFuture<MutableList<MediaItem>> {
|
|
790
|
-
// Resolve URIs for Android Auto / external controllers
|
|
791
847
|
val resolved = mediaItems.map { item ->
|
|
792
848
|
item.buildUpon().setUri(item.requestMetadata.mediaUri).build()
|
|
793
849
|
}.toMutableList()
|
|
794
850
|
return com.google.common.util.concurrent.Futures.immediateFuture(resolved)
|
|
795
851
|
}
|
|
796
852
|
}
|
|
797
|
-
}
|
|
853
|
+
}
|
|
@@ -6,29 +6,12 @@ import UIKit
|
|
|
6
6
|
|
|
7
7
|
public typealias EventEmitter = (String, [String: Any]?) -> Void
|
|
8
8
|
|
|
9
|
-
// React Native's promise block types
|
|
10
|
-
// no dependency on a bridging header (`import <React/RCTBridgeModule.h>`).
|
|
11
|
-
// Bridging headers are unsupported on framework targets in Xcode, and this
|
|
12
|
-
// pod builds as a static_framework — so this file must never `import React`
|
|
13
|
-
// or rely on one being injected. These signatures are structurally
|
|
14
|
-
// identical to the real `RCTPromiseResolveBlock` / `RCTPromiseRejectBlock`
|
|
15
|
-
// (id -> Any?, NSString* -> String?, NSError* -> Error?), so the resulting
|
|
16
|
-
// @objc blocks are ABI-compatible with what GliphPlayerModule.mm expects.
|
|
9
|
+
// React Native's promise block types
|
|
17
10
|
public typealias RCTPromiseResolveBlock = (Any?) -> Void
|
|
18
11
|
public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
19
12
|
|
|
20
13
|
// MARK: - GliphAudioPlayer
|
|
21
14
|
|
|
22
|
-
/**
|
|
23
|
-
* GliphAudioPlayer
|
|
24
|
-
*
|
|
25
|
-
* Core Swift class that manages:
|
|
26
|
-
* - AVQueuePlayer for audio playback
|
|
27
|
-
* - MPRemoteCommandCenter for lock screen / headphone controls
|
|
28
|
-
* - MPNowPlayingInfoCenter for Now Playing metadata
|
|
29
|
-
* - Audio session configuration
|
|
30
|
-
* - Queue management
|
|
31
|
-
*/
|
|
32
15
|
@objc public class GliphAudioPlayer: NSObject {
|
|
33
16
|
|
|
34
17
|
// ── Properties ──────────────────────────────────────────────────────────────
|
|
@@ -37,7 +20,7 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
37
20
|
private var playerItems: [AVPlayerItem] = []
|
|
38
21
|
private var queue: [[String: Any]] = []
|
|
39
22
|
private var currentIndex: Int = -1
|
|
40
|
-
private var repeatMode: Int = 0
|
|
23
|
+
private var repeatMode: Int = 0
|
|
41
24
|
private var isSetup = false
|
|
42
25
|
private var progressTimer: Timer?
|
|
43
26
|
private var progressInterval: TimeInterval = 1.0
|
|
@@ -56,12 +39,6 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
56
39
|
|
|
57
40
|
// ── Thread helper ───────────────────────────────────────────────────────────
|
|
58
41
|
|
|
59
|
-
/// Executes `block` synchronously on the main thread.
|
|
60
|
-
/// Safe to call from any thread — avoids deadlock if already on main.
|
|
61
|
-
/// Several AVAudioSession / UIApplication / MPRemoteCommandCenter APIs are
|
|
62
|
-
/// documented as main-thread-only; the RN bridge does not guarantee that
|
|
63
|
-
/// native module methods run on main, so every touch of those APIs goes
|
|
64
|
-
/// through this helper.
|
|
65
42
|
private func runOnMain(_ block: () -> Void) {
|
|
66
43
|
if Thread.isMainThread {
|
|
67
44
|
block()
|
|
@@ -145,11 +122,19 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
145
122
|
? queue.count
|
|
146
123
|
: insertBeforeIndex
|
|
147
124
|
|
|
125
|
+
let wasEmpty = queue.isEmpty
|
|
126
|
+
|
|
148
127
|
for (i, track) in tracks.enumerated() {
|
|
149
128
|
queue.insert(track, at: insertAt + i)
|
|
150
129
|
}
|
|
151
130
|
|
|
152
131
|
rebuildPlayerQueue()
|
|
132
|
+
|
|
133
|
+
if wasEmpty && !queue.isEmpty {
|
|
134
|
+
currentIndex = 0
|
|
135
|
+
emitActiveTrackChanged()
|
|
136
|
+
}
|
|
137
|
+
|
|
153
138
|
resolve(insertAt)
|
|
154
139
|
}
|
|
155
140
|
|
|
@@ -183,12 +168,18 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
183
168
|
guard index >= 0 && index < queue.count else {
|
|
184
169
|
reject("skip_error", "Index out of bounds", nil); return
|
|
185
170
|
}
|
|
171
|
+
|
|
172
|
+
let lastIndex = currentIndex
|
|
186
173
|
currentIndex = index
|
|
174
|
+
|
|
187
175
|
rebuildPlayerQueue()
|
|
188
176
|
if initialPosition >= 0 {
|
|
189
177
|
player?.seek(to: CMTime(seconds: initialPosition, preferredTimescale: 1000))
|
|
190
178
|
}
|
|
191
179
|
player?.play()
|
|
180
|
+
|
|
181
|
+
emitActiveTrackChanged(lastIndex: lastIndex)
|
|
182
|
+
|
|
192
183
|
resolve(nil)
|
|
193
184
|
}
|
|
194
185
|
|
|
@@ -237,11 +228,19 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
237
228
|
@objc public func play(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
238
229
|
player?.play()
|
|
239
230
|
startProgressTimer()
|
|
231
|
+
|
|
232
|
+
eventEmitter("playback-state", ["state": "playing"])
|
|
233
|
+
|
|
234
|
+
if currentIndex >= 0 && currentIndex < queue.count {
|
|
235
|
+
emitActiveTrackChanged()
|
|
236
|
+
}
|
|
237
|
+
|
|
240
238
|
resolve(nil)
|
|
241
239
|
}
|
|
242
240
|
|
|
243
241
|
@objc public func pause(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
244
242
|
player?.pause()
|
|
243
|
+
eventEmitter("playback-state", ["state": "paused"])
|
|
245
244
|
resolve(nil)
|
|
246
245
|
}
|
|
247
246
|
|
|
@@ -249,6 +248,7 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
249
248
|
player?.pause()
|
|
250
249
|
player?.seek(to: .zero)
|
|
251
250
|
stopProgressTimer()
|
|
251
|
+
eventEmitter("playback-state", ["state": "stopped"])
|
|
252
252
|
resolve(nil)
|
|
253
253
|
}
|
|
254
254
|
|
|
@@ -257,9 +257,19 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
257
257
|
player?.removeAllItems()
|
|
258
258
|
queue.removeAll()
|
|
259
259
|
playerItems.removeAll()
|
|
260
|
+
let lastIndex = currentIndex
|
|
260
261
|
currentIndex = -1
|
|
261
262
|
stopProgressTimer()
|
|
262
263
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
264
|
+
|
|
265
|
+
eventEmitter("playback-active-track-changed", [
|
|
266
|
+
"index": -1,
|
|
267
|
+
"track": NSNull(),
|
|
268
|
+
"lastIndex": lastIndex,
|
|
269
|
+
"lastPosition": 0.0,
|
|
270
|
+
] as [String : Any])
|
|
271
|
+
eventEmitter("playback-state", ["state": "none"])
|
|
272
|
+
|
|
263
273
|
resolve(nil)
|
|
264
274
|
}
|
|
265
275
|
|
|
@@ -392,6 +402,18 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
392
402
|
|
|
393
403
|
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
394
404
|
|
|
405
|
+
private func emitActiveTrackChanged(lastIndex: Int = -1) {
|
|
406
|
+
let lastPos = CMTimeGetSeconds(player?.currentTime() ?? .zero)
|
|
407
|
+
let track = (currentIndex >= 0 && currentIndex < queue.count) ? queue[currentIndex] : nil
|
|
408
|
+
|
|
409
|
+
eventEmitter("playback-active-track-changed", [
|
|
410
|
+
"index": currentIndex,
|
|
411
|
+
"track": track as Any,
|
|
412
|
+
"lastIndex": lastIndex >= 0 ? lastIndex : currentIndex,
|
|
413
|
+
"lastPosition": lastPos,
|
|
414
|
+
])
|
|
415
|
+
}
|
|
416
|
+
|
|
395
417
|
private func rebuildPlayerQueue() {
|
|
396
418
|
guard let player = player else { return }
|
|
397
419
|
player.removeAllItems()
|
|
@@ -407,7 +429,9 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
407
429
|
player.insert(item, after: player.items().last)
|
|
408
430
|
}
|
|
409
431
|
|
|
410
|
-
if currentIndex < 0 && !queue.isEmpty {
|
|
432
|
+
if currentIndex < 0 && !queue.isEmpty {
|
|
433
|
+
currentIndex = 0
|
|
434
|
+
}
|
|
411
435
|
updateNowPlayingInfo()
|
|
412
436
|
}
|
|
413
437
|
|
|
@@ -430,7 +454,6 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
430
454
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = CMTimeGetSeconds(player?.currentTime() ?? .zero)
|
|
431
455
|
info[MPNowPlayingInfoPropertyPlaybackRate] = player?.rate ?? 0.0
|
|
432
456
|
|
|
433
|
-
// Artwork
|
|
434
457
|
if let artworkURL = track["artwork"] as? String, !artworkURL.isEmpty {
|
|
435
458
|
loadArtwork(from: artworkURL) { image in
|
|
436
459
|
if let image = image {
|
|
@@ -467,10 +490,6 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
467
490
|
|
|
468
491
|
// ── Remote commands ─────────────────────────────────────────────────────────
|
|
469
492
|
|
|
470
|
-
/// Must always be called on the main thread (see `runOnMain` call sites:
|
|
471
|
-
/// `setupPlayer` and `updateOptions`). `MPRemoteCommandCenter` and
|
|
472
|
-
/// `UIApplication.beginReceivingRemoteControlEvents()` are main-thread-only
|
|
473
|
-
/// APIs.
|
|
474
493
|
private func setupRemoteCommands() {
|
|
475
494
|
let center = MPRemoteCommandCenter.shared()
|
|
476
495
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
|
@@ -588,7 +607,6 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
588
607
|
object: nil
|
|
589
608
|
)
|
|
590
609
|
|
|
591
|
-
// Observe player status
|
|
592
610
|
if let player = player {
|
|
593
611
|
let obs = player.observe(\.timeControlStatus, options: [.new]) { [weak self] p, _ in
|
|
594
612
|
self?.eventEmitter("playback-state", ["state": self?.currentStateString() ?? "none"])
|
|
@@ -609,16 +627,19 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
609
627
|
}
|
|
610
628
|
|
|
611
629
|
@objc private func playerItemDidFinish(_ notification: Notification) {
|
|
612
|
-
// Only react to notifications for our own player's current item —
|
|
613
|
-
// `object: nil` on the observer means this fires for *any* AVPlayerItem
|
|
614
|
-
// in the whole app (e.g. a video player elsewhere), so we must filter here.
|
|
615
630
|
guard let finishedItem = notification.object as? AVPlayerItem,
|
|
616
631
|
finishedItem === player?.currentItem else { return }
|
|
617
632
|
|
|
618
633
|
let nextIndex = currentIndex + 1
|
|
634
|
+
let currentPosition = CMTimeGetSeconds(player?.currentTime() ?? .zero)
|
|
635
|
+
|
|
636
|
+
// ✅ 1. Отправляем событие окончания ТЕКУЩЕГО трека
|
|
637
|
+
eventEmitter("playback-track-ended", [
|
|
638
|
+
"index": currentIndex,
|
|
639
|
+
"position": currentPosition
|
|
640
|
+
])
|
|
619
641
|
|
|
620
642
|
if repeatMode == 1 {
|
|
621
|
-
// Repeat current track
|
|
622
643
|
player?.seek(to: .zero)
|
|
623
644
|
player?.play()
|
|
624
645
|
return
|
|
@@ -634,14 +655,16 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
634
655
|
"lastPosition": 0.0,
|
|
635
656
|
])
|
|
636
657
|
} else if repeatMode == 2 {
|
|
637
|
-
// Repeat queue
|
|
638
658
|
currentIndex = 0
|
|
639
659
|
rebuildPlayerQueue()
|
|
640
660
|
player?.play()
|
|
661
|
+
if !queue.isEmpty {
|
|
662
|
+
emitActiveTrackChanged()
|
|
663
|
+
}
|
|
641
664
|
} else {
|
|
642
665
|
eventEmitter("playback-queue-ended", [
|
|
643
666
|
"index": currentIndex,
|
|
644
|
-
"position":
|
|
667
|
+
"position": currentPosition,
|
|
645
668
|
])
|
|
646
669
|
}
|
|
647
670
|
}
|
|
@@ -677,7 +700,6 @@ public typealias RCTPromiseRejectBlock = (String?, String?, Error?) -> Void
|
|
|
677
700
|
@objc private func audioRouteChanged(_ notification: Notification) {
|
|
678
701
|
guard let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
679
702
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
|
|
680
|
-
// Pause on headphone unplug (standard iOS behavior)
|
|
681
703
|
if reason == .oldDeviceUnavailable { player?.pause() }
|
|
682
704
|
}
|
|
683
705
|
|
package/ios/GliphPlayerModule.mm
CHANGED
|
@@ -37,6 +37,7 @@ RCT_EXPORT_MODULE(RNGliphPlayer)
|
|
|
37
37
|
@"playback-state",
|
|
38
38
|
@"playback-error",
|
|
39
39
|
@"playback-active-track-changed",
|
|
40
|
+
@"playback-track-ended",
|
|
40
41
|
@"playback-queue-ended",
|
|
41
42
|
@"playback-progress-updated",
|
|
42
43
|
@"playback-metadata-received",
|
|
@@ -240,4 +241,4 @@ RCT_EXPORT_METHOD(updateOptions:(NSDictionary *)options
|
|
|
240
241
|
}
|
|
241
242
|
#endif
|
|
242
243
|
|
|
243
|
-
@end
|
|
244
|
+
@end
|
package/package.json
CHANGED