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 // default: music
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 // default
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 + (i), track)
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
- // Emit track ended for the last item in the queue if it just finished
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
- // Then emit queue ended
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
- // 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) {
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, redeclared locally so this file has
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 // 0=off, 1=track, 2=queue
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 { currentIndex = 0 }
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": CMTimeGetSeconds(player?.currentTime() ?? .zero),
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
 
@@ -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
@@ -4,7 +4,7 @@
4
4
  "name": "Aleksey Ostrikov",
5
5
  "email": "alex303606@gmail.com"
6
6
  },
7
- "version": "1.3.13",
7
+ "version": "1.3.14",
8
8
  "description": "Fixed version of react-native-gliph-player with Expo compatibility",
9
9
  "main": "app.plugin.js",
10
10
  "module": "lib/module/index.js",