react-native-nitro-player 1.7.0-alpha.1 → 1.7.0-alpha.2

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.
@@ -58,12 +58,16 @@ internal class TrackPlayerEventListener(
58
58
  currentTemporaryType = determineCurrentTemporaryType()
59
59
 
60
60
  // Update currentTrackIndex when landing on an original playlist track
61
+ val previousTrackIndex = currentTrackIndex
61
62
  if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && mediaItem != null) {
62
63
  val trackId = extractTrackId(mediaItem.mediaId)
63
64
  val newIdx = currentTracks.indexOfFirst { it.id == trackId }
64
65
  if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
65
66
  }
66
67
 
68
+ val wrappedToStart = currentRepeatMode == RepeatMode.PLAYLIST &&
69
+ currentTrackIndex == 0 && previousTrackIndex == currentTracks.size - 1
70
+
67
71
  // Top up the windowed timeline — the item we just left freed a slot.
68
72
  // Only when it is actually short: rebuildQueueFromCurrentPosition can fall
69
73
  // into the substitute-jump path (setMediaItems), which re-enters this very
@@ -77,7 +81,8 @@ internal class TrackPlayerEventListener(
77
81
  val track = getCurrentTrack() ?: return
78
82
  val r =
79
83
  when (reason) {
80
- Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> Reason.END
84
+ Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ->
85
+ if (wrappedToStart) Reason.REPEAT else Reason.END
81
86
  Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> Reason.USER_ACTION
82
87
  Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> Reason.USER_ACTION
83
88
  else -> null
@@ -74,6 +74,7 @@ internal fun TrackPlayerCore.skipToPreviousOnQueue() {
74
74
  suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) = withPlayerContext { setRepeatModeOnQueue(mode) }
75
75
 
76
76
  internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
77
+ val previousMode = currentRepeatMode
77
78
  currentRepeatMode = mode
78
79
  exo.setRepeatMode(
79
80
  when (mode) {
@@ -81,6 +82,11 @@ internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
81
82
  else -> Player.REPEAT_MODE_OFF
82
83
  },
83
84
  )
85
+ if (!isCastingField && previousMode != mode &&
86
+ (previousMode == RepeatMode.PLAYLIST || mode == RepeatMode.PLAYLIST)
87
+ ) {
88
+ rebuildQueueFromCurrentPosition()
89
+ }
84
90
  }
85
91
 
86
92
  fun TrackPlayerCore.getRepeatMode(): RepeatMode = currentRepeatMode
@@ -7,6 +7,7 @@ import androidx.media3.common.MediaItem
7
7
  import androidx.media3.common.MediaMetadata
8
8
  import androidx.media3.common.Player
9
9
  import com.margelo.nitro.nitroplayer.Reason
10
+ import com.margelo.nitro.nitroplayer.RepeatMode
10
11
  import com.margelo.nitro.nitroplayer.TrackItem
11
12
 
12
13
  /**
@@ -121,6 +122,12 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
121
122
  val currentId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
122
123
  var newQueueTracks: List<TrackItem> = buildUpcomingQueueTracks(currentId)
123
124
 
125
+ if (!isCastingField && currentRepeatMode == RepeatMode.PLAYLIST &&
126
+ currentTrackIndex > 0 && currentTrackIndex <= currentTracks.size
127
+ ) {
128
+ newQueueTracks = newQueueTracks + currentTracks.subList(0, currentTrackIndex)
129
+ }
130
+
124
131
  if (isCastingField) {
125
132
  newQueueTracks = castableUpcoming(newQueueTracks)
126
133
 
@@ -91,6 +91,7 @@ class TrackPlayerCore: NSObject {
91
91
  // track, not a real track change — so it must not emit onChangeTrack or reset
92
92
  // per-track state. Consumed (cleared) by the next `currentItemDidChange`.
93
93
  internal var suppressTrackChangeEmit = false
94
+ internal var lastCurrentItemTrackId: String?
94
95
  // Last observed playback position, used to resume after recreating a failed item.
95
96
  internal var lastKnownPosition: Double = 0
96
97
  // Per-track retry budget for recreating AVPlayerItems that hit status == .failed.
@@ -173,10 +173,12 @@ extension TrackPlayerCore {
173
173
  guard finishedItem.trackId != nil else { return }
174
174
 
175
175
  // 1. TRACK repeat — handle FIRST, before any temp-track removal
176
- if currentRepeatMode == .track, finishedItem === player?.currentItem {
177
- NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — seeking to zero and replaying")
178
- player?.seek(to: .zero)
179
- player?.rate = Float(currentPlaybackSpeed)
176
+ if currentRepeatMode == .track {
177
+ if player?.currentItem == nil || player?.currentItem === finishedItem {
178
+ NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — no queued copy, seeking to zero")
179
+ player?.seek(to: .zero)
180
+ player?.rate = Float(currentPlaybackSpeed)
181
+ }
180
182
  return // do not remove temp tracks, do not notify track change (same track looping)
181
183
  }
182
184
 
@@ -293,6 +295,7 @@ extension TrackPlayerCore {
293
295
  guard let player, let currentItem = player.currentItem else {
294
296
  NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Current item changed to nil")
295
297
  detachMetadataOutput()
298
+ lastCurrentItemTrackId = nil
296
299
  // Queue exhausted — handle PLAYLIST repeat
297
300
  if currentRepeatMode == .playlist && !currentTracks.isEmpty, let player = self.player {
298
301
  NitroPlayerLogger.log("TrackPlayerCore", "🔁 PLAYLIST repeat — rebuilding original queue and restarting")
@@ -318,6 +321,11 @@ extension TrackPlayerCore {
318
321
  return
319
322
  }
320
323
 
324
+ let isRepeatLoop =
325
+ currentRepeatMode == .track && currentItem.trackId != nil
326
+ && currentItem.trackId == lastCurrentItemTrackId
327
+ lastCurrentItemTrackId = currentItem.trackId
328
+
321
329
  #if DEBUG
322
330
  NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "▶", count: Constants.separatorLineLength))
323
331
  NitroPlayerLogger.log("TrackPlayerCore", "🔄 CURRENT ITEM CHANGED")
@@ -367,9 +375,9 @@ extension TrackPlayerCore {
367
375
  if currentTemporaryType == .playNext { tempTrack = playNextStack.first(where: { $0.id == trackId }) }
368
376
  else if currentTemporaryType == .upNext { tempTrack = upNextQueue.first(where: { $0.id == trackId }) }
369
377
  if let track = tempTrack {
370
- if isRecoveryReplace {
378
+ if isRecoveryReplace || isRepeatLoop {
371
379
  NitroPlayerLogger.log("TrackPlayerCore",
372
- " ♻️ Recovery replace of temporary track '\(track.title)' — suppressing onChangeTrack")
380
+ " ♻️ Same-track transition for '\(track.title)' — suppressing onChangeTrack")
373
381
  } else {
374
382
  NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Temporary track: \(track.title) - \(track.artist)")
375
383
  NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack for temporary track")
@@ -387,7 +395,9 @@ extension TrackPlayerCore {
387
395
  NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Track: \(track.title) - \(track.artist)")
388
396
  if oldIndex != index {
389
397
  NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack (index changed from \(oldIndex) to \(index))")
390
- notifyTrackChange(track, .skip)
398
+ let wrappedToStart =
399
+ currentRepeatMode == .playlist && index == 0 && oldIndex == currentTracks.count - 1
400
+ notifyTrackChange(track, wrappedToStart ? .repeat : .skip)
391
401
  } else {
392
402
  NitroPlayerLogger.log("TrackPlayerCore", " ⏭️ Skipping onChangeTrack emission (index unchanged)")
393
403
  }
@@ -413,7 +423,7 @@ extension TrackPlayerCore {
413
423
  // rebuildQueueFromPlaylistIndex (current track no longer in the playlist), which
414
424
  // replaces the current item and re-enters this callback. The length check makes any
415
425
  // such re-entry a no-op and terminate.
416
- if player.items().count - 1 < Constants.queueWindowSize {
426
+ if currentRepeatMode == .track || player.items().count - 1 < Constants.queueWindowSize {
417
427
  rebuildAVQueueFromCurrentPosition()
418
428
  }
419
429
 
@@ -72,9 +72,13 @@ extension TrackPlayerCore {
72
72
  func skipToPrevious() async { await withPlayerQueueNoThrow { self.skipToPreviousOnQueue() } }
73
73
 
74
74
  func setRepeatModeOnQueue(mode: RepeatMode) {
75
+ let previousMode = currentRepeatMode
75
76
  currentRepeatMode = mode
76
- player?.actionAtItemEnd = (mode == .track) ? .none : .advance
77
+ player?.actionAtItemEnd = .advance
77
78
  if isCasting { castManager?.setQueueRepeatMode(mode) }
79
+ if !isCasting, previousMode != mode, player?.currentItem != nil {
80
+ rebuildAVQueueFromCurrentPosition()
81
+ }
78
82
  NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
79
83
  }
80
84
  func setRepeatMode(mode: RepeatMode) async {
@@ -254,6 +258,14 @@ extension TrackPlayerCore {
254
258
  return
255
259
  }
256
260
 
261
+ if currentRepeatMode == .track {
262
+ let items = queuePlayer.items()
263
+ if items.count > 1, let copyId = items[1].trackId, copyId == items[0].trackId {
264
+ queuePlayer.remove(items[1])
265
+ }
266
+ rebuildAVQueueFromCurrentPosition(skipRepeatCopy: true)
267
+ }
268
+
257
269
  // Remove current temp track from its list before advancing
258
270
  if let trackId = queuePlayer.currentItem?.trackId {
259
271
  if currentTemporaryType == .playNext {
@@ -272,7 +272,9 @@ extension TrackPlayerCore {
272
272
  ///
273
273
  /// - Parameter changedTrackIds: When non-nil, performs a surgical update:
274
274
  /// only AVPlayerItems whose track ID is in this set are removed and re-created.
275
- func rebuildAVQueueFromCurrentPosition(changedTrackIds: Set<String>? = nil) {
275
+ func rebuildAVQueueFromCurrentPosition(
276
+ changedTrackIds: Set<String>? = nil, skipRepeatCopy: Bool = false
277
+ ) {
276
278
  // While casting, queue changes are applied to the receiver — the local AVQueuePlayer is dormant.
277
279
  if isCasting {
278
280
  syncCastQueueAfterCurrent()
@@ -341,6 +343,18 @@ extension TrackPlayerCore {
341
343
  newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
342
344
  }
343
345
 
346
+ if currentRepeatMode == .playlist, currentTrackIndex > 0, currentTrackIndex <= currentTracks.count {
347
+ newQueueTracks.append(contentsOf: currentTracks[0..<currentTrackIndex])
348
+ }
349
+
350
+ if currentRepeatMode == .track, !skipRepeatCopy {
351
+ let repeated =
352
+ playNextStack.first(where: { $0.id == playingTrackId })
353
+ ?? upNextQueue.first(where: { $0.id == playingTrackId })
354
+ ?? currentTracks.first(where: { $0.id == playingTrackId })
355
+ if let repeated = repeated { newQueueTracks = [repeated] }
356
+ }
357
+
344
358
  // Window the materialized queue — currentItemDidChange tops it up on every transition.
345
359
  if newQueueTracks.count > Constants.queueWindowSize {
346
360
  newQueueTracks.removeSubrange(Constants.queueWindowSize...)
@@ -530,7 +544,10 @@ extension TrackPlayerCore {
530
544
  }
531
545
 
532
546
  let asset: AVURLAsset
533
- if let preloadedAsset = preloadedAssets[track.id] {
547
+ let preloaded = preloadedAssets[track.id]
548
+ let preloadedInUse =
549
+ preloaded != nil && (player?.items().contains { ($0.asset as? AVURLAsset) === preloaded } ?? false)
550
+ if let preloadedAsset = preloaded, !preloadedInUse {
534
551
  asset = preloadedAsset
535
552
  NitroPlayerLogger.log("TrackPlayerCore", "🚀 Using preloaded asset for \(track.title)")
536
553
  } else {
@@ -571,9 +588,13 @@ extension TrackPlayerCore {
571
588
  let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
572
589
  let alreadyPreloaded = Set(preloadedAssets.keys)
573
590
  let generation = preloadGeneration
574
- let endIndex = min(startIndex + Constants.gaplessPreloadCount, currentTracks.count)
575
- guard startIndex >= 0, startIndex < endIndex else { return }
576
- let candidates = Array(currentTracks[startIndex..<endIndex])
591
+ let wraps = currentRepeatMode == .playlist && !currentTracks.isEmpty
592
+ let available =
593
+ wraps
594
+ ? min(Constants.gaplessPreloadCount, currentTracks.count)
595
+ : max(0, min(startIndex + Constants.gaplessPreloadCount, currentTracks.count) - startIndex)
596
+ guard startIndex >= 0, available > 0 else { return }
597
+ let candidates = (0..<available).map { currentTracks[(startIndex + $0) % currentTracks.count] }
577
598
 
578
599
  preloadQueue.async { [weak self] in
579
600
  guard let self else { return }
@@ -631,10 +652,20 @@ extension TrackPlayerCore {
631
652
  /// Clears preloaded assets that are no longer needed
632
653
  func cleanupPreloadedAssets(keepingFrom currentIndex: Int) {
633
654
  // Already on playerQueue — access preloadedAssets directly
634
- let keepRange =
635
- currentIndex..<min(
636
- currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
637
- let keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
655
+ let keepIds: Set<String>
656
+ if currentRepeatMode == .playlist, !self.currentTracks.isEmpty {
657
+ let start = max(0, currentIndex)
658
+ let keepCount = min(Constants.gaplessPreloadCount + 1, self.currentTracks.count)
659
+ keepIds = Set(
660
+ (0..<keepCount).compactMap {
661
+ self.currentTracks[safe: (start + $0) % self.currentTracks.count]?.id
662
+ })
663
+ } else {
664
+ let keepRange =
665
+ currentIndex..<min(
666
+ currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
667
+ keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
668
+ }
638
669
 
639
670
  let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
640
671
  for id in assetsToRemove {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-player",
3
- "version": "1.7.0-alpha.1",
3
+ "version": "1.7.0-alpha.2",
4
4
  "description": "A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",