react-native-nitro-player 0.5.9-alpha.0 → 0.6.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.
@@ -41,6 +41,28 @@ class TrackPlayerCore private constructor(
41
41
  private val handler = android.os.Handler(android.os.Looper.getMainLooper())
42
42
  private lateinit var player: ExoPlayer
43
43
  private val playlistManager = PlaylistManager.getInstance(context)
44
+
45
+ // Named Runnable so handler.removeCallbacks() can coalesce rapid playlist
46
+ // mutations (e.g. N individual removes followed by a batch add during shuffle)
47
+ // into a single player update, preventing audio gaps on Android.
48
+ private val updateCurrentPlaylistRunnable = Runnable {
49
+ val playlistId = currentPlaylistId ?: return@Runnable
50
+ val playlist = playlistManager.getPlaylist(playlistId) ?: return@Runnable
51
+
52
+ // Always update the canonical track list first.
53
+ currentTracks = playlist.tracks
54
+
55
+ if (::player.isInitialized && player.currentMediaItem != null && player.currentMediaItemIndex >= 0) {
56
+ // Something is actively playing — rebuild only the items AFTER the
57
+ // current position using surgical removeMediaItems/addMediaItems.
58
+ // This avoids setMediaItems() which replaces the entire ExoPlayer
59
+ // queue (including the current item) and causes an audible gap.
60
+ rebuildQueueFromCurrentPosition()
61
+ } else {
62
+ // Nothing playing yet — safe to do a full replace.
63
+ updatePlayerQueue(playlist.tracks)
64
+ }
65
+ }
44
66
  private val downloadManager = DownloadManagerCore.getInstance(context)
45
67
  private val mediaLibraryManager = MediaLibraryManager.getInstance(context)
46
68
  private var mediaSessionManager: MediaSessionManager? = null
@@ -84,6 +106,7 @@ class TrackPlayerCore private constructor(
84
106
 
85
107
  @Volatile private var currentRepeatMode: RepeatMode = RepeatMode.OFF
86
108
  private var lookaheadCount: Int = 5 // Number of tracks to preload ahead
109
+ private var playerListener: Player.Listener? = null
87
110
 
88
111
  // Temporary tracks for addToUpNext and playNext
89
112
  private var playNextStack: MutableList<TrackItem> = mutableListOf() // LIFO - last added plays first
@@ -177,8 +200,7 @@ class TrackPlayerCore private constructor(
177
200
  registerCarConnectionReceiver()
178
201
  }
179
202
 
180
- player.addListener(
181
- object : Player.Listener {
203
+ val listener = object : Player.Listener {
182
204
  override fun onMediaItemTransition(
183
205
  mediaItem: MediaItem?,
184
206
  reason: Int,
@@ -353,8 +375,9 @@ class TrackPlayerCore private constructor(
353
375
  }
354
376
  }
355
377
  }
356
- },
357
- )
378
+ }
379
+ playerListener = listener
380
+ player.addListener(listener)
358
381
 
359
382
  // Start progress updates
360
383
  handler.post(progressUpdateRunnable)
@@ -433,14 +456,13 @@ class TrackPlayerCore private constructor(
433
456
  * Update the player queue when playlist changes
434
457
  */
435
458
  fun updatePlaylist(playlistId: String) {
436
- handler.post {
437
- if (currentPlaylistId == playlistId) {
438
- val playlist = playlistManager.getPlaylist(playlistId)
439
- if (playlist != null) {
440
- updatePlayerQueue(playlist.tracks)
441
- }
442
- }
443
- }
459
+ // Debounce: rapid back-to-back calls (e.g. removing N tracks then adding
460
+ // the shuffled replacement) are coalesced into a single setMediaItems call.
461
+ // removeCallbacks cancels any pending-but-not-yet-executed callback so only
462
+ // the final playlist state triggers a player rebuild.
463
+ if (currentPlaylistId != playlistId) return
464
+ handler.removeCallbacks(updateCurrentPlaylistRunnable)
465
+ handler.post(updateCurrentPlaylistRunnable)
444
466
  }
445
467
 
446
468
  /**
@@ -1266,6 +1288,8 @@ class TrackPlayerCore private constructor(
1266
1288
  handler.post {
1267
1289
  androidAutoConnectionDetector?.unregisterCarConnectionReceiver()
1268
1290
  handler.removeCallbacks(progressUpdateRunnable)
1291
+ playerListener?.let { player.removeListener(it) }
1292
+ playerListener = null
1269
1293
  }
1270
1294
  }
1271
1295
 
@@ -48,6 +48,10 @@ class TrackPlayerCore: NSObject {
48
48
  private var currentPlaylistId: String?
49
49
  private var currentTrackIndex: Int = -1
50
50
  private var currentTracks: [TrackItem] = []
51
+ // Debounce work item — rapid playlist mutations (e.g. N individual removes
52
+ // during shuffle) are coalesced into a single rebuildAVQueueFromCurrentPosition
53
+ // call, preventing audio gaps/interruptions on iOS.
54
+ private var pendingPlaylistUpdateWorkItem: DispatchWorkItem?
51
55
  private var isManuallySeeked = false
52
56
  private var currentRepeatMode: RepeatMode = .off
53
57
  private var lookaheadCount: Int = 5 // Number of tracks to preload ahead
@@ -58,6 +62,10 @@ class TrackPlayerCore: NSObject {
58
62
  private var preloadedAssets: [String: AVURLAsset] = [:]
59
63
  private let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
60
64
 
65
+ // Debounce flag: prevents firing checkUpcomingTracksForUrls every boundary tick
66
+ // once we've already requested URLs for the current track's remaining window.
67
+ private var didRequestUrlsForCurrentItem = false
68
+
61
69
  // Temporary tracks for addToUpNext and playNext
62
70
  private var playNextStack: [TrackItem] = [] // LIFO - last added plays first
63
71
  private var upNextQueue: [TrackItem] = [] // FIFO - first added plays first
@@ -141,6 +149,25 @@ class TrackPlayerCore: NSObject {
141
149
 
142
150
  NitroPlayerLogger.log("TrackPlayerCore", "🎵 Gapless playback configured - automaticallyWaitsToMinimizeStalling=true (flipped to false on first readyToPlay)")
143
151
 
152
+ // Listen for EQ enabled/disabled changes so we can update ALL items in
153
+ // the queue atomically, keeping the audio pipeline configuration uniform.
154
+ // A mismatch (some items with tap, some without) forces AVQueuePlayer to
155
+ // reconfigure the pipeline at transition boundaries → audible gap.
156
+ EqualizerCore.shared.addOnEnabledChangeListener(owner: self) { [weak self] enabled in
157
+ guard let self = self, let player = self.player else { return }
158
+ DispatchQueue.main.async {
159
+ for item in player.items() {
160
+ if enabled {
161
+ EqualizerCore.shared.applyAudioMix(to: item)
162
+ } else {
163
+ item.audioMix = nil
164
+ }
165
+ }
166
+ NitroPlayerLogger.log("TrackPlayerCore",
167
+ "🎛️ EQ toggled \(enabled ? "ON" : "OFF") — updated \(player.items().count) items for pipeline consistency")
168
+ }
169
+ }
170
+
144
171
  setupPlayerObservers()
145
172
  }
146
173
 
@@ -227,26 +254,15 @@ class TrackPlayerCore: NSObject {
227
254
  interval = Constants.boundaryIntervalDefault
228
255
  }
229
256
 
230
- // Create boundary times at each interval
231
- var boundaryTimes: [NSValue] = []
232
- boundaryTimes.reserveCapacity(Int(duration / interval) + 1)
233
- var time: Double = 0
234
- while time <= duration {
235
- let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
236
- boundaryTimes.append(NSValue(time: cmTime))
237
- time += interval
238
- }
239
-
240
- NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Setting up \(boundaryTimes.count) boundary observers (interval: \(interval)s, duration: \(Int(duration))s)")
257
+ NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Setting up periodic observer (interval: \(interval)s, duration: \(Int(duration))s)")
241
258
 
242
- // Add boundary time observer
243
- boundaryTimeObserver = player.addBoundaryTimeObserver(forTimes: boundaryTimes, queue: .main) {
244
- [weak self] in
245
- guard let self = self else { return }
246
- self.handleBoundaryTimeCrossed()
259
+ let cmInterval = CMTime(seconds: interval, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
260
+ boundaryTimeObserver = player.addPeriodicTimeObserver(forInterval: cmInterval, queue: .main) {
261
+ [weak self] _ in
262
+ self?.handleBoundaryTimeCrossed()
247
263
  }
248
264
 
249
- NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Boundary time observer setup complete")
265
+ NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Periodic time observer setup complete")
250
266
  }
251
267
 
252
268
  private func handleBoundaryTimeCrossed() {
@@ -270,6 +286,17 @@ class TrackPlayerCore: NSObject {
270
286
  isManuallySeeked ? true : nil
271
287
  )
272
288
  isManuallySeeked = false
289
+
290
+ // Proactive gapless URL resolution: when the track is within the
291
+ // buffer window of its end, check if any upcoming tracks still
292
+ // need URLs and fire the callback so JS can resolve them in time.
293
+ let remaining = duration - position
294
+ if remaining > 0 && remaining <= Constants.preferredForwardBufferDuration && !didRequestUrlsForCurrentItem {
295
+ didRequestUrlsForCurrentItem = true
296
+ NitroPlayerLogger.log("TrackPlayerCore",
297
+ "⏳ \(Int(remaining))s remaining — proactively checking upcoming URLs")
298
+ checkUpcomingTracksForUrls(lookahead: lookaheadCount)
299
+ }
273
300
  }
274
301
 
275
302
  // MARK: - Notification Handlers
@@ -395,6 +422,9 @@ class TrackPlayerCore: NSObject {
395
422
  // Clear old item observers
396
423
  currentItemObservers.removeAll()
397
424
 
425
+ // Reset proactive URL check debounce for the new track
426
+ didRequestUrlsForCurrentItem = false
427
+
398
428
  // Track changed - update index
399
429
  guard let player = player,
400
430
  let currentItem = player.currentItem
@@ -623,7 +653,13 @@ class TrackPlayerCore: NSObject {
623
653
  }
624
654
 
625
655
  func updatePlaylist(playlistId: String) {
626
- DispatchQueue.main.async { [weak self] in
656
+ guard currentPlaylistId == playlistId else { return }
657
+
658
+ // Cancel any pending rebuild so back-to-back calls (e.g. N individual removes
659
+ // during shuffle) collapse into a single rebuild at the end.
660
+ pendingPlaylistUpdateWorkItem?.cancel()
661
+
662
+ let workItem = DispatchWorkItem { [weak self] in
627
663
  guard let self = self else { return }
628
664
  guard self.currentPlaylistId == playlistId,
629
665
  let playlist = self.playlistManager.getPlaylist(playlistId: playlistId)
@@ -641,6 +677,9 @@ class TrackPlayerCore: NSObject {
641
677
  // Rebuild only the items after the currently playing item
642
678
  self.rebuildAVQueueFromCurrentPosition()
643
679
  }
680
+
681
+ pendingPlaylistUpdateWorkItem = workItem
682
+ DispatchQueue.main.async(execute: workItem)
644
683
  }
645
684
 
646
685
  // MARK: - Public Methods
@@ -721,18 +760,17 @@ class TrackPlayerCore: NSObject {
721
760
  asset = preloadedAsset
722
761
  NitroPlayerLogger.log("TrackPlayerCore", "🚀 Using preloaded asset for \(track.title)")
723
762
  } else {
724
- // No AVURLAssetPreferPreciseDurationAndTimingKey gapless playback is achieved via
725
- // AVQueuePlayer's internal audio buffer pre-roll, not timing metadata.
726
- // Precise timing only helps with accurate VBR duration display, at the cost of
727
- // deep file scanning that delays readyToPlay.
728
- asset = AVURLAsset(url: url)
763
+ asset = AVURLAsset(url: url, options: [
764
+ AVURLAssetPreferPreciseDurationAndTimingKey: true
765
+ ])
729
766
  }
730
767
 
731
768
  let item = AVPlayerItem(asset: asset)
732
769
 
733
- // Configure buffer duration for gapless playback
734
- // This tells AVPlayer how much content to buffer ahead
735
- item.preferredForwardBufferDuration = Constants.preferredForwardBufferDuration
770
+ // Let the system choose the optimal forward buffer size (0 = automatic).
771
+ // An explicit cap (e.g. 30 s) limits how much of the *next* queued item
772
+ // AVQueuePlayer pre-rolls, which can cause audible gaps on HTTP streams.
773
+ item.preferredForwardBufferDuration = 0
736
774
 
737
775
  // Enable automatic loading of item properties for faster starts
738
776
  item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
@@ -740,14 +778,10 @@ class TrackPlayerCore: NSObject {
740
778
  // Store track ID for later reference
741
779
  item.trackId = track.id
742
780
 
743
- // Apply equalizer audio mix to the player item
744
- // This enables real-time EQ processing via MTAudioProcessingTap
745
- // Apply equalizer audio mix to the player item
746
- // This enables real-time EQ processing via MTAudioProcessingTap
747
- EqualizerCore.shared.applyAudioMix(to: item)
748
- NitroPlayerLogger.log("TrackPlayerCore", "🎛️ Requesting EQ audio mix application for \(track.title)")
749
-
750
- // If this is a preload request, start loading asset keys asynchronously
781
+ // If this is a preload request, start loading asset keys asynchronously.
782
+ // EQ is applied INSIDE the completion handler so the "tracks" key is
783
+ // already loaded applyAudioMix takes the synchronous fast-path and
784
+ // the tap is attached before AVQueuePlayer pre-rolls the item.
751
785
  if isPreload {
752
786
  asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
753
787
  // Asset keys are now loaded, which speeds up playback start
@@ -763,7 +797,13 @@ class TrackPlayerCore: NSObject {
763
797
  if allKeysLoaded {
764
798
  NitroPlayerLogger.log("TrackPlayerCore", "✅ All asset keys preloaded for \(track.title)")
765
799
  }
800
+ // "tracks" key is now loaded — EQ tap attaches synchronously
801
+ EqualizerCore.shared.applyAudioMix(to: item)
766
802
  }
803
+ } else {
804
+ // Non-preload: asset may already have keys loaded (preloadedAssets cache)
805
+ // so applyAudioMix will use the sync path if possible, async otherwise.
806
+ EqualizerCore.shared.applyAudioMix(to: item)
767
807
  }
768
808
 
769
809
  return item
@@ -771,6 +811,13 @@ class TrackPlayerCore: NSObject {
771
811
 
772
812
  /// Preloads assets for upcoming tracks to enable gapless playback
773
813
  private func preloadUpcomingTracks(from startIndex: Int) {
814
+ // Capture the set of track IDs that already have AVPlayerItems in the
815
+ // queue (main-thread access). Creating duplicate AVURLAssets for these
816
+ // would start parallel HTTP downloads for the same URLs, competing
817
+ // with AVQueuePlayer's own pre-roll buffering and potentially starving
818
+ // the next-item buffer — resulting in an audible gap at the transition.
819
+ let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
820
+
774
821
  preloadQueue.async { [weak self] in
775
822
  guard let self = self else { return }
776
823
 
@@ -782,14 +829,26 @@ class TrackPlayerCore: NSObject {
782
829
  guard i < tracks.count else { break }
783
830
  let track = tracks[i]
784
831
 
785
- // Skip if already preloaded
786
- if self.preloadedAssets[track.id] != nil {
832
+ // Skip if already preloaded OR already in the player queue
833
+ if self.preloadedAssets[track.id] != nil || queuedTrackIds.contains(track.id) {
787
834
  continue
788
835
  }
789
836
 
790
- guard let url = URL(string: track.url) else { continue }
837
+ // Use effective URL so downloaded tracks preload from disk, not network
838
+ let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
839
+ let isLocal = effectiveUrlString.hasPrefix("/")
791
840
 
792
- let asset = AVURLAsset(url: url)
841
+ let url: URL
842
+ if isLocal {
843
+ url = URL(fileURLWithPath: effectiveUrlString)
844
+ } else {
845
+ guard let remoteUrl = URL(string: effectiveUrlString) else { continue }
846
+ url = remoteUrl
847
+ }
848
+
849
+ let asset = AVURLAsset(url: url, options: [
850
+ AVURLAssetPreferPreciseDurationAndTimingKey: true
851
+ ])
793
852
 
794
853
  // Preload essential keys for gapless playback
795
854
  asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
@@ -1007,10 +1066,12 @@ class TrackPlayerCore: NSObject {
1007
1066
  NitroPlayerLogger.log("TrackPlayerCore", "🔄 Removing \(existingPlayer.items().count) old items from player")
1008
1067
  existingPlayer.removeAllItems()
1009
1068
 
1010
- // Lazy-load mode: if any track has no URL yet, don't populate AVQueuePlayer items now.
1011
- // Adding downloaded tracks at incorrect positions would cause the wrong track to play.
1012
- // updateTracks will rebuild from the correct currentTrackIndex once URLs are resolved.
1013
- let isLazyLoad = tracks.contains { $0.url.isEmpty }
1069
+ // Lazy-load mode: if any track has no URL AND is not downloaded locally,
1070
+ // we can't create an AVPlayerItem for it and the queue order would be wrong.
1071
+ // Downloaded tracks with empty remote URLs still play from disk via getEffectiveUrl.
1072
+ let isLazyLoad = tracks.contains {
1073
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
1074
+ }
1014
1075
  if isLazyLoad {
1015
1076
  NitroPlayerLogger.log("TrackPlayerCore", "⏳ Lazy-load mode — player cleared, awaiting URL resolution")
1016
1077
  return
@@ -1718,12 +1779,12 @@ class TrackPlayerCore: NSObject {
1718
1779
  // Update currentTrackIndex BEFORE updating queue
1719
1780
  self.currentTrackIndex = index
1720
1781
 
1721
- // Lazy-load guard: if the target track itself has no URL yet, the queue can't be built.
1722
- // Emit the track change now (so UI reflects the navigation) and defer queue population
1723
- // to updateTracks once URL resolution completes.
1724
- // Only check the target — other unresolved tracks elsewhere in the playlist shouldn't
1725
- // block a skip to a track that is already playable.
1726
- let isLazyLoad = fullPlaylist[index].url.isEmpty
1782
+ // Lazy-load guard: if the target track has no URL AND is not downloaded locally,
1783
+ // the queue can't be built. Defer to updateTracks once URL resolution completes.
1784
+ // Downloaded tracks play from disk via getEffectiveUrl — no remote URL needed.
1785
+ let targetTrack = fullPlaylist[index]
1786
+ let isLazyLoad = targetTrack.url.isEmpty
1787
+ && !DownloadManagerCore.shared.isTrackDownloaded(trackId: targetTrack.id)
1727
1788
  if isLazyLoad {
1728
1789
  NitroPlayerLogger.log("TrackPlayerCore", " ⏳ Lazy-load — deferring AVQueuePlayer setup; emitting track change for index \(index)")
1729
1790
  self.currentTracks = fullPlaylist
@@ -1855,17 +1916,26 @@ class TrackPlayerCore: NSObject {
1855
1916
  /**
1856
1917
  * Rebuild the AVQueuePlayer from current position with temporary tracks
1857
1918
  * Order: [current] + [playNext stack reversed] + [upNext queue] + [remaining original]
1919
+ *
1920
+ * - Parameter changedTrackIds: When non-nil, performs a **surgical** update:
1921
+ * only AVPlayerItems whose track ID is in this set are removed and re-created.
1922
+ * All other pre-buffered items are left in place and new items are inserted
1923
+ * around them. This preserves AVQueuePlayer's internal audio pre-roll buffers
1924
+ * for gapless inter-track transitions.
1925
+ * When nil, the queue is fully torn down and rebuilt (used by skip, reorder,
1926
+ * addToUpNext, playNext, etc.).
1858
1927
  */
1859
- private func rebuildAVQueueFromCurrentPosition() {
1928
+ private func rebuildAVQueueFromCurrentPosition(changedTrackIds: Set<String>? = nil) {
1860
1929
  guard let player = self.player else { return }
1861
1930
 
1862
1931
  let currentItem = player.currentItem
1863
1932
  let playingItems = player.items()
1864
1933
 
1934
+ // ---- Build the desired upcoming track list ----
1935
+
1865
1936
  var newQueueTracks: [TrackItem] = []
1866
1937
 
1867
1938
  // Add playNext stack (LIFO - most recently added plays first)
1868
- // Skip index 0 if current track is from playNext (it's already playing)
1869
1939
  if currentTemporaryType == .playNext && playNextStack.count > 1 {
1870
1940
  newQueueTracks.append(contentsOf: playNextStack.dropFirst())
1871
1941
  } else if currentTemporaryType != .playNext {
@@ -1873,7 +1943,6 @@ class TrackPlayerCore: NSObject {
1873
1943
  }
1874
1944
 
1875
1945
  // Add upNext queue (in order, FIFO)
1876
- // Skip index 0 if current track is from upNext (it's already playing)
1877
1946
  if currentTemporaryType == .upNext && upNextQueue.count > 1 {
1878
1947
  newQueueTracks.append(contentsOf: upNextQueue.dropFirst())
1879
1948
  } else if currentTemporaryType != .upNext {
@@ -1885,12 +1954,84 @@ class TrackPlayerCore: NSObject {
1885
1954
  newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
1886
1955
  }
1887
1956
 
1888
- // Remove all items from player EXCEPT the currently playing one
1957
+ // ---- Collect existing upcoming AVPlayerItems ----
1958
+
1959
+ let upcomingItems: [AVPlayerItem]
1960
+ if let ci = currentItem, let ciIndex = playingItems.firstIndex(of: ci) {
1961
+ upcomingItems = Array(playingItems.suffix(from: playingItems.index(after: ciIndex)))
1962
+ } else {
1963
+ upcomingItems = []
1964
+ }
1965
+
1966
+ let existingIds = upcomingItems.compactMap { $0.trackId }
1967
+ let desiredIds = newQueueTracks.map { $0.id }
1968
+
1969
+ // ---- Fast-path: nothing to do if queue already matches ----
1970
+
1971
+ if existingIds == desiredIds {
1972
+ if let changedIds = changedTrackIds {
1973
+ if Set(existingIds).isDisjoint(with: changedIds) {
1974
+ NitroPlayerLogger.log("TrackPlayerCore",
1975
+ "✅ Queue matches & no buffered URLs changed — preserving \(existingIds.count) items for gapless")
1976
+ return
1977
+ }
1978
+ } else {
1979
+ NitroPlayerLogger.log("TrackPlayerCore",
1980
+ "✅ Queue already matches desired order — preserving \(existingIds.count) items for gapless")
1981
+ return
1982
+ }
1983
+ }
1984
+
1985
+ // ---- Surgical path (changedTrackIds provided, e.g. from updateTracks) ----
1986
+ // Only remove items whose URLs actually changed; insert newly-resolved items
1987
+ // in the correct positions around existing, pre-buffered items.
1988
+
1989
+ if let changedIds = changedTrackIds {
1990
+ // Build lookup of reusable (un-changed) items by track ID
1991
+ var reusableByTrackId: [String: AVPlayerItem] = [:]
1992
+ for item in upcomingItems {
1993
+ if let trackId = item.trackId, !changedIds.contains(trackId) {
1994
+ reusableByTrackId[trackId] = item
1995
+ }
1996
+ }
1997
+
1998
+ // Remove only items whose URLs changed
1999
+ let desiredIdSet = Set(desiredIds)
2000
+ for item in upcomingItems {
2001
+ guard let trackId = item.trackId else { continue }
2002
+ if changedIds.contains(trackId) || !desiredIdSet.contains(trackId) {
2003
+ player.remove(item)
2004
+ }
2005
+ }
2006
+
2007
+ // Walk through the desired order, inserting new items around the
2008
+ // reusable items that are still sitting in the queue untouched.
2009
+ var lastAnchor: AVPlayerItem? = currentItem
2010
+ for trackId in desiredIds {
2011
+ if let reusable = reusableByTrackId[trackId] {
2012
+ // Item is still in the queue at its original position — advance anchor
2013
+ lastAnchor = reusable
2014
+ } else if let track = newQueueTracks.first(where: { $0.id == trackId }),
2015
+ let newItem = createGaplessPlayerItem(for: track, isPreload: false)
2016
+ {
2017
+ player.insert(newItem, after: lastAnchor)
2018
+ lastAnchor = newItem
2019
+ }
2020
+ }
2021
+
2022
+ let preserved = reusableByTrackId.count
2023
+ let inserted = desiredIds.count - preserved
2024
+ NitroPlayerLogger.log("TrackPlayerCore",
2025
+ "🔄 Surgical rebuild: preserved \(preserved) buffered items, inserted \(inserted) new items")
2026
+ return
2027
+ }
2028
+
2029
+ // ---- Full rebuild path (no changedTrackIds — skip, reorder, etc.) ----
2030
+
1889
2031
  for item in playingItems where item != currentItem {
1890
2032
  player.remove(item)
1891
2033
  }
1892
2034
 
1893
- // Insert new items in order
1894
2035
  var lastItem = currentItem
1895
2036
  for track in newQueueTracks {
1896
2037
  if let item = createGaplessPlayerItem(for: track, isPreload: false) {
@@ -1898,7 +2039,6 @@ class TrackPlayerCore: NSObject {
1898
2039
  lastItem = item
1899
2040
  }
1900
2041
  }
1901
-
1902
2042
  }
1903
2043
 
1904
2044
  /**
@@ -1960,7 +2100,11 @@ class TrackPlayerCore: NSObject {
1960
2100
  // Get current track to decide how to handle it
1961
2101
  let currentTrack = self.getCurrentTrack()
1962
2102
  let currentTrackId = currentTrack?.id
1963
- let currentTrackIsEmpty = currentTrack?.url.isEmpty ?? false
2103
+ // A track is only "empty" if it has no remote URL AND is not downloaded.
2104
+ // Downloaded tracks with empty .url are playing from disk — don't replace them.
2105
+ let currentTrackIsEmpty = currentTrack.map {
2106
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2107
+ } ?? false
1964
2108
 
1965
2109
  // Filter out current track and validate
1966
2110
  let safeTracks = tracks.filter { track in
@@ -2047,7 +2191,9 @@ class TrackPlayerCore: NSObject {
2047
2191
  self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
2048
2192
  } else {
2049
2193
  // A current AVPlayerItem already exists — preserve it and only rebuild upcoming items.
2050
- self.rebuildAVQueueFromCurrentPosition()
2194
+ // Pass the set of track IDs whose URLs actually changed so the rebuild
2195
+ // can keep already-buffered items intact for gapless transitions.
2196
+ self.rebuildAVQueueFromCurrentPosition(changedTrackIds: updatedTrackIds)
2051
2197
  // Re-preload upcoming tracks for gapless playback
2052
2198
  // CRITICAL: This restores gapless buffering after queue rebuild
2053
2199
  self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
@@ -2099,7 +2245,11 @@ class TrackPlayerCore: NSObject {
2099
2245
  return []
2100
2246
  }
2101
2247
 
2102
- return playlist.tracks.filter { $0.url.isEmpty }
2248
+ // Only return tracks that truly can't play: empty remote URL AND not
2249
+ // downloaded locally. Downloaded tracks play from disk via getEffectiveUrl.
2250
+ return playlist.tracks.filter {
2251
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2252
+ }
2103
2253
  }
2104
2254
 
2105
2255
  /**
@@ -2196,12 +2346,18 @@ class TrackPlayerCore: NSObject {
2196
2346
  private func checkUpcomingTracksForUrls(lookahead: Int = 5) {
2197
2347
  let upcomingTracks = getNextTracksInternal(count: lookahead)
2198
2348
 
2199
- // Always include the current track if it has no URL — it can't play without one
2349
+ // Always include the current track if it has no URL and isn't downloaded — it can't play without one
2200
2350
  let currentTrack = getCurrentTrack()
2201
- let currentNeedsUrl = currentTrack.map { $0.url.isEmpty } ?? false
2351
+ let currentNeedsUrl = currentTrack.map {
2352
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2353
+ } ?? false
2202
2354
  let candidateTracks = currentNeedsUrl ? [currentTrack!] + upcomingTracks : upcomingTracks
2203
2355
 
2204
- let tracksNeedingUrls = candidateTracks.filter { $0.url.isEmpty }
2356
+ // Only request URLs for tracks that truly can't play: empty remote URL
2357
+ // AND not downloaded locally (downloaded tracks play from disk via getEffectiveUrl).
2358
+ let tracksNeedingUrls = candidateTracks.filter {
2359
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2360
+ }
2205
2361
 
2206
2362
  if !tracksNeedingUrls.isEmpty {
2207
2363
  NitroPlayerLogger.log(
@@ -91,11 +91,35 @@ class EqualizerCore {
91
91
 
92
92
  // MARK: - Audio Mix Creation for AVPlayerItem
93
93
 
94
- /// Applies an AVAudioMix with equalizer processing for the given AVPlayerItem asynchronously
94
+ /// Applies an AVAudioMix with equalizer processing for the given AVPlayerItem.
95
+ /// No-ops when the equalizer is disabled so that AVPlayerItems remain tap-free,
96
+ /// keeping the audio pipeline configuration identical across all queued items
97
+ /// and allowing AVQueuePlayer to perform seamless gapless transitions.
98
+ ///
99
+ /// When the asset's "tracks" key is already loaded (e.g. from preloading),
100
+ /// the mix is applied **synchronously** so the item enters AVQueuePlayer with
101
+ /// the correct tap from the start — avoiding a pipeline reconfiguration that
102
+ /// causes an audible gap at the transition.
95
103
  func applyAudioMix(to playerItem: AVPlayerItem) {
104
+ guard isEqualizerEnabled else {
105
+ // Ensure no stale tap remains from a previous enable/disable cycle
106
+ if playerItem.audioMix != nil {
107
+ playerItem.audioMix = nil
108
+ }
109
+ return
110
+ }
111
+
96
112
  let asset = playerItem.asset
97
113
 
98
- // Load "tracks" key asynchronously to avoid blocking
114
+ // Fast path: "tracks" already loaded (from preloadUpcomingTracks) — apply synchronously.
115
+ var keyError: NSError?
116
+ if asset.statusOfValue(forKey: "tracks", error: &keyError) == .loaded {
117
+ buildAndApplyAudioMix(to: playerItem, asset: asset)
118
+ NitroPlayerLogger.log("EqualizerCore", "✅ Applied audio mix with EQ tap to player item (sync — preloaded)")
119
+ return
120
+ }
121
+
122
+ // Slow path: load "tracks" key asynchronously to avoid blocking
99
123
  asset.loadValuesAsynchronously(forKeys: ["tracks"]) { [weak self] in
100
124
  guard let self = self else { return }
101
125
 
@@ -113,50 +137,55 @@ class EqualizerCore {
113
137
  return
114
138
  }
115
139
 
116
- guard let audioTrack = asset.tracks(withMediaType: .audio).first else {
117
- NitroPlayerLogger.log("EqualizerCore", "⚠️ No audio track found in asset")
118
- return
119
- }
140
+ // Apply directly audioMix is thread-safe and applying on the
141
+ // loadValues completion queue avoids a main-thread hop that would
142
+ // delay tap attachment and risk a pipeline mismatch at pre-roll time.
143
+ self.buildAndApplyAudioMix(to: playerItem, asset: asset)
144
+ NitroPlayerLogger.log("EqualizerCore", "✅ Applied audio mix with EQ tap to player item (async)")
145
+ }
146
+ }
120
147
 
121
- // Create audio mix input parameters
122
- let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
123
-
124
- // Create the audio processing tap
125
- var callbacks = MTAudioProcessingTapCallbacks(
126
- version: kMTAudioProcessingTapCallbacksVersion_0,
127
- clientInfo: UnsafeMutableRawPointer(Unmanaged.passRetained(self).toOpaque()),
128
- init: tapInitCallback,
129
- finalize: tapFinalizeCallback,
130
- prepare: tapPrepareCallback,
131
- unprepare: tapUnprepareCallback,
132
- process: tapProcessCallback
133
- )
148
+ /// Creates an MTAudioProcessingTap-backed AVAudioMix and sets it on the player item.
149
+ /// Must be called when the asset's "tracks" key is already loaded.
150
+ private func buildAndApplyAudioMix(to playerItem: AVPlayerItem, asset: AVAsset) {
151
+ guard let audioTrack = asset.tracks(withMediaType: .audio).first else {
152
+ NitroPlayerLogger.log("EqualizerCore", "⚠️ No audio track found in asset")
153
+ return
154
+ }
134
155
 
135
- var tap: MTAudioProcessingTap?
136
- let createStatus = MTAudioProcessingTapCreate(
137
- kCFAllocatorDefault,
138
- &callbacks,
139
- kMTAudioProcessingTapCreationFlag_PreEffects,
140
- &tap
141
- )
156
+ // Create audio mix input parameters
157
+ let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
158
+
159
+ // Create the audio processing tap
160
+ var callbacks = MTAudioProcessingTapCallbacks(
161
+ version: kMTAudioProcessingTapCallbacksVersion_0,
162
+ clientInfo: UnsafeMutableRawPointer(Unmanaged.passRetained(self).toOpaque()),
163
+ init: tapInitCallback,
164
+ finalize: tapFinalizeCallback,
165
+ prepare: tapPrepareCallback,
166
+ unprepare: tapUnprepareCallback,
167
+ process: tapProcessCallback
168
+ )
142
169
 
143
- guard createStatus == noErr, let audioTap = tap else {
144
- NitroPlayerLogger.log("EqualizerCore", "❌ Failed to create audio processing tap, status: \(createStatus)")
145
- return
146
- }
170
+ var tap: MTAudioProcessingTap?
171
+ let createStatus = MTAudioProcessingTapCreate(
172
+ kCFAllocatorDefault,
173
+ &callbacks,
174
+ kMTAudioProcessingTapCreationFlag_PreEffects,
175
+ &tap
176
+ )
147
177
 
148
- inputParams.audioTapProcessor = audioTap
178
+ guard createStatus == noErr, let audioTap = tap else {
179
+ NitroPlayerLogger.log("EqualizerCore", "❌ Failed to create audio processing tap, status: \(createStatus)")
180
+ return
181
+ }
149
182
 
150
- // Create audio mix
151
- let audioMix = AVMutableAudioMix()
152
- audioMix.inputParameters = [inputParams]
183
+ inputParams.audioTapProcessor = audioTap
153
184
 
154
- // Apply to player item on main thread (AVPlayerItem properties should be accessed/modified on main thread or serial queue usually, but audioMix is thread safe - safely done on main to be sure)
155
- DispatchQueue.main.async {
156
- playerItem.audioMix = audioMix
157
- NitroPlayerLogger.log("EqualizerCore", "✅ Applied audio mix with EQ tap to player item (async)")
158
- }
159
- }
185
+ // Create and apply audio mix
186
+ let audioMix = AVMutableAudioMix()
187
+ audioMix.inputParameters = [inputParams]
188
+ playerItem.audioMix = audioMix
160
189
  }
161
190
 
162
191
  // MARK: - Public Methods
@@ -41,6 +41,9 @@ export function usePlaylist() {
41
41
  const [isLoading, setIsLoading] = useState(true);
42
42
  const isMounted = useRef(true);
43
43
  const hasSubscribed = useRef(false);
44
+ // Tracks the last fetched playlist ID so track-change events can skip
45
+ // a full refresh when the playlist itself hasn't changed.
46
+ const lastPlaylistIdRef = useRef(undefined);
44
47
  const refreshPlaylists = useCallback(() => {
45
48
  if (!isMounted.current)
46
49
  return;
@@ -49,6 +52,7 @@ export function usePlaylist() {
49
52
  const playlistId = PlayerQueue.getCurrentPlaylistId();
50
53
  if (!isMounted.current)
51
54
  return;
55
+ lastPlaylistIdRef.current = playlistId;
52
56
  setCurrentPlaylistId(playlistId);
53
57
  // Get current playlist details
54
58
  if (playlistId) {
@@ -88,6 +92,24 @@ export function usePlaylist() {
88
92
  }
89
93
  }
90
94
  }, []);
95
+ // Lightweight track-change handler: only does a full refresh when the
96
+ // active playlist ID has actually changed (e.g. cross-playlist navigation).
97
+ // Within-playlist track changes — the common case — are skipped with a
98
+ // single bridge call instead of three.
99
+ const refreshOnTrackChange = useCallback(() => {
100
+ if (!isMounted.current)
101
+ return;
102
+ try {
103
+ const newPlaylistId = PlayerQueue.getCurrentPlaylistId();
104
+ if (newPlaylistId === lastPlaylistIdRef.current)
105
+ return;
106
+ lastPlaylistIdRef.current = newPlaylistId;
107
+ refreshPlaylists();
108
+ }
109
+ catch (error) {
110
+ console.error('[usePlaylist] Error checking playlist ID on track change:', error);
111
+ }
112
+ }, [refreshPlaylists]);
91
113
  // Initialize and setup mounted ref
92
114
  useEffect(() => {
93
115
  isMounted.current = true;
@@ -113,18 +135,17 @@ export function usePlaylist() {
113
135
  console.error('[usePlaylist] Error setting up playlist listener:', error);
114
136
  }
115
137
  }, [refreshPlaylists]);
116
- // Also refresh when track changes (as it might indicate playlist loaded)
138
+ // Refresh on track change only if the active playlist ID changed.
117
139
  useEffect(() => {
118
140
  const unsubscribe = callbackManager.subscribeToTrackChange(() => {
119
- // Refresh to update currentPlaylistId when track changes
120
141
  if (isMounted.current) {
121
- refreshPlaylists();
142
+ refreshOnTrackChange();
122
143
  }
123
144
  });
124
145
  return () => {
125
146
  unsubscribe();
126
147
  };
127
- }, [refreshPlaylists]);
148
+ }, [refreshOnTrackChange]);
128
149
  return {
129
150
  currentPlaylist,
130
151
  currentPlaylistId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-player",
3
- "version": "0.5.9-alpha.0",
3
+ "version": "0.6.0",
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",
@@ -60,6 +60,9 @@ export function usePlaylist(): UsePlaylistResult {
60
60
  const [isLoading, setIsLoading] = useState(true)
61
61
  const isMounted = useRef(true)
62
62
  const hasSubscribed = useRef(false)
63
+ // Tracks the last fetched playlist ID so track-change events can skip
64
+ // a full refresh when the playlist itself hasn't changed.
65
+ const lastPlaylistIdRef = useRef<string | null | undefined>(undefined)
63
66
 
64
67
  const refreshPlaylists = useCallback(() => {
65
68
  if (!isMounted.current) return
@@ -68,6 +71,7 @@ export function usePlaylist(): UsePlaylistResult {
68
71
  // Get current playlist ID
69
72
  const playlistId = PlayerQueue.getCurrentPlaylistId()
70
73
  if (!isMounted.current) return
74
+ lastPlaylistIdRef.current = playlistId
71
75
  setCurrentPlaylistId(playlistId)
72
76
 
73
77
  // Get current playlist details
@@ -108,6 +112,25 @@ export function usePlaylist(): UsePlaylistResult {
108
112
  }
109
113
  }, [])
110
114
 
115
+ // Lightweight track-change handler: only does a full refresh when the
116
+ // active playlist ID has actually changed (e.g. cross-playlist navigation).
117
+ // Within-playlist track changes — the common case — are skipped with a
118
+ // single bridge call instead of three.
119
+ const refreshOnTrackChange = useCallback(() => {
120
+ if (!isMounted.current) return
121
+ try {
122
+ const newPlaylistId = PlayerQueue.getCurrentPlaylistId()
123
+ if (newPlaylistId === lastPlaylistIdRef.current) return
124
+ lastPlaylistIdRef.current = newPlaylistId
125
+ refreshPlaylists()
126
+ } catch (error) {
127
+ console.error(
128
+ '[usePlaylist] Error checking playlist ID on track change:',
129
+ error
130
+ )
131
+ }
132
+ }, [refreshPlaylists])
133
+
111
134
  // Initialize and setup mounted ref
112
135
  useEffect(() => {
113
136
  isMounted.current = true
@@ -136,19 +159,18 @@ export function usePlaylist(): UsePlaylistResult {
136
159
  }
137
160
  }, [refreshPlaylists])
138
161
 
139
- // Also refresh when track changes (as it might indicate playlist loaded)
162
+ // Refresh on track change only if the active playlist ID changed.
140
163
  useEffect(() => {
141
164
  const unsubscribe = callbackManager.subscribeToTrackChange(() => {
142
- // Refresh to update currentPlaylistId when track changes
143
165
  if (isMounted.current) {
144
- refreshPlaylists()
166
+ refreshOnTrackChange()
145
167
  }
146
168
  })
147
169
 
148
170
  return () => {
149
171
  unsubscribe()
150
172
  }
151
- }, [refreshPlaylists])
173
+ }, [refreshOnTrackChange])
152
174
 
153
175
  return {
154
176
  currentPlaylist,