react-native-nitro-player 0.5.9-alpha.0 → 0.5.9-alpha.1

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.
@@ -84,6 +84,7 @@ class TrackPlayerCore private constructor(
84
84
 
85
85
  @Volatile private var currentRepeatMode: RepeatMode = RepeatMode.OFF
86
86
  private var lookaheadCount: Int = 5 // Number of tracks to preload ahead
87
+ private var playerListener: Player.Listener? = null
87
88
 
88
89
  // Temporary tracks for addToUpNext and playNext
89
90
  private var playNextStack: MutableList<TrackItem> = mutableListOf() // LIFO - last added plays first
@@ -177,8 +178,7 @@ class TrackPlayerCore private constructor(
177
178
  registerCarConnectionReceiver()
178
179
  }
179
180
 
180
- player.addListener(
181
- object : Player.Listener {
181
+ val listener = object : Player.Listener {
182
182
  override fun onMediaItemTransition(
183
183
  mediaItem: MediaItem?,
184
184
  reason: Int,
@@ -353,8 +353,9 @@ class TrackPlayerCore private constructor(
353
353
  }
354
354
  }
355
355
  }
356
- },
357
- )
356
+ }
357
+ playerListener = listener
358
+ player.addListener(listener)
358
359
 
359
360
  // Start progress updates
360
361
  handler.post(progressUpdateRunnable)
@@ -1266,6 +1267,8 @@ class TrackPlayerCore private constructor(
1266
1267
  handler.post {
1267
1268
  androidAutoConnectionDetector?.unregisterCarConnectionReceiver()
1268
1269
  handler.removeCallbacks(progressUpdateRunnable)
1270
+ playerListener?.let { player.removeListener(it) }
1271
+ playerListener = null
1269
1272
  }
1270
1273
  }
1271
1274
 
@@ -58,6 +58,10 @@ class TrackPlayerCore: NSObject {
58
58
  private var preloadedAssets: [String: AVURLAsset] = [:]
59
59
  private let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
60
60
 
61
+ // Debounce flag: prevents firing checkUpcomingTracksForUrls every boundary tick
62
+ // once we've already requested URLs for the current track's remaining window.
63
+ private var didRequestUrlsForCurrentItem = false
64
+
61
65
  // Temporary tracks for addToUpNext and playNext
62
66
  private var playNextStack: [TrackItem] = [] // LIFO - last added plays first
63
67
  private var upNextQueue: [TrackItem] = [] // FIFO - first added plays first
@@ -141,6 +145,25 @@ class TrackPlayerCore: NSObject {
141
145
 
142
146
  NitroPlayerLogger.log("TrackPlayerCore", "🎵 Gapless playback configured - automaticallyWaitsToMinimizeStalling=true (flipped to false on first readyToPlay)")
143
147
 
148
+ // Listen for EQ enabled/disabled changes so we can update ALL items in
149
+ // the queue atomically, keeping the audio pipeline configuration uniform.
150
+ // A mismatch (some items with tap, some without) forces AVQueuePlayer to
151
+ // reconfigure the pipeline at transition boundaries → audible gap.
152
+ EqualizerCore.shared.addOnEnabledChangeListener(owner: self) { [weak self] enabled in
153
+ guard let self = self, let player = self.player else { return }
154
+ DispatchQueue.main.async {
155
+ for item in player.items() {
156
+ if enabled {
157
+ EqualizerCore.shared.applyAudioMix(to: item)
158
+ } else {
159
+ item.audioMix = nil
160
+ }
161
+ }
162
+ NitroPlayerLogger.log("TrackPlayerCore",
163
+ "🎛️ EQ toggled \(enabled ? "ON" : "OFF") — updated \(player.items().count) items for pipeline consistency")
164
+ }
165
+ }
166
+
144
167
  setupPlayerObservers()
145
168
  }
146
169
 
@@ -227,26 +250,15 @@ class TrackPlayerCore: NSObject {
227
250
  interval = Constants.boundaryIntervalDefault
228
251
  }
229
252
 
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)")
253
+ NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Setting up periodic observer (interval: \(interval)s, duration: \(Int(duration))s)")
241
254
 
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()
255
+ let cmInterval = CMTime(seconds: interval, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
256
+ boundaryTimeObserver = player.addPeriodicTimeObserver(forInterval: cmInterval, queue: .main) {
257
+ [weak self] _ in
258
+ self?.handleBoundaryTimeCrossed()
247
259
  }
248
260
 
249
- NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Boundary time observer setup complete")
261
+ NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Periodic time observer setup complete")
250
262
  }
251
263
 
252
264
  private func handleBoundaryTimeCrossed() {
@@ -270,6 +282,17 @@ class TrackPlayerCore: NSObject {
270
282
  isManuallySeeked ? true : nil
271
283
  )
272
284
  isManuallySeeked = false
285
+
286
+ // Proactive gapless URL resolution: when the track is within the
287
+ // buffer window of its end, check if any upcoming tracks still
288
+ // need URLs and fire the callback so JS can resolve them in time.
289
+ let remaining = duration - position
290
+ if remaining > 0 && remaining <= Constants.preferredForwardBufferDuration && !didRequestUrlsForCurrentItem {
291
+ didRequestUrlsForCurrentItem = true
292
+ NitroPlayerLogger.log("TrackPlayerCore",
293
+ "⏳ \(Int(remaining))s remaining — proactively checking upcoming URLs")
294
+ checkUpcomingTracksForUrls(lookahead: lookaheadCount)
295
+ }
273
296
  }
274
297
 
275
298
  // MARK: - Notification Handlers
@@ -395,6 +418,9 @@ class TrackPlayerCore: NSObject {
395
418
  // Clear old item observers
396
419
  currentItemObservers.removeAll()
397
420
 
421
+ // Reset proactive URL check debounce for the new track
422
+ didRequestUrlsForCurrentItem = false
423
+
398
424
  // Track changed - update index
399
425
  guard let player = player,
400
426
  let currentItem = player.currentItem
@@ -721,18 +747,17 @@ class TrackPlayerCore: NSObject {
721
747
  asset = preloadedAsset
722
748
  NitroPlayerLogger.log("TrackPlayerCore", "🚀 Using preloaded asset for \(track.title)")
723
749
  } 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)
750
+ asset = AVURLAsset(url: url, options: [
751
+ AVURLAssetPreferPreciseDurationAndTimingKey: true
752
+ ])
729
753
  }
730
754
 
731
755
  let item = AVPlayerItem(asset: asset)
732
756
 
733
- // Configure buffer duration for gapless playback
734
- // This tells AVPlayer how much content to buffer ahead
735
- item.preferredForwardBufferDuration = Constants.preferredForwardBufferDuration
757
+ // Let the system choose the optimal forward buffer size (0 = automatic).
758
+ // An explicit cap (e.g. 30 s) limits how much of the *next* queued item
759
+ // AVQueuePlayer pre-rolls, which can cause audible gaps on HTTP streams.
760
+ item.preferredForwardBufferDuration = 0
736
761
 
737
762
  // Enable automatic loading of item properties for faster starts
738
763
  item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
@@ -740,14 +765,10 @@ class TrackPlayerCore: NSObject {
740
765
  // Store track ID for later reference
741
766
  item.trackId = track.id
742
767
 
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
768
+ // If this is a preload request, start loading asset keys asynchronously.
769
+ // EQ is applied INSIDE the completion handler so the "tracks" key is
770
+ // already loaded applyAudioMix takes the synchronous fast-path and
771
+ // the tap is attached before AVQueuePlayer pre-rolls the item.
751
772
  if isPreload {
752
773
  asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
753
774
  // Asset keys are now loaded, which speeds up playback start
@@ -763,7 +784,13 @@ class TrackPlayerCore: NSObject {
763
784
  if allKeysLoaded {
764
785
  NitroPlayerLogger.log("TrackPlayerCore", "✅ All asset keys preloaded for \(track.title)")
765
786
  }
787
+ // "tracks" key is now loaded — EQ tap attaches synchronously
788
+ EqualizerCore.shared.applyAudioMix(to: item)
766
789
  }
790
+ } else {
791
+ // Non-preload: asset may already have keys loaded (preloadedAssets cache)
792
+ // so applyAudioMix will use the sync path if possible, async otherwise.
793
+ EqualizerCore.shared.applyAudioMix(to: item)
767
794
  }
768
795
 
769
796
  return item
@@ -771,6 +798,13 @@ class TrackPlayerCore: NSObject {
771
798
 
772
799
  /// Preloads assets for upcoming tracks to enable gapless playback
773
800
  private func preloadUpcomingTracks(from startIndex: Int) {
801
+ // Capture the set of track IDs that already have AVPlayerItems in the
802
+ // queue (main-thread access). Creating duplicate AVURLAssets for these
803
+ // would start parallel HTTP downloads for the same URLs, competing
804
+ // with AVQueuePlayer's own pre-roll buffering and potentially starving
805
+ // the next-item buffer — resulting in an audible gap at the transition.
806
+ let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
807
+
774
808
  preloadQueue.async { [weak self] in
775
809
  guard let self = self else { return }
776
810
 
@@ -782,14 +816,26 @@ class TrackPlayerCore: NSObject {
782
816
  guard i < tracks.count else { break }
783
817
  let track = tracks[i]
784
818
 
785
- // Skip if already preloaded
786
- if self.preloadedAssets[track.id] != nil {
819
+ // Skip if already preloaded OR already in the player queue
820
+ if self.preloadedAssets[track.id] != nil || queuedTrackIds.contains(track.id) {
787
821
  continue
788
822
  }
789
823
 
790
- guard let url = URL(string: track.url) else { continue }
824
+ // Use effective URL so downloaded tracks preload from disk, not network
825
+ let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
826
+ let isLocal = effectiveUrlString.hasPrefix("/")
827
+
828
+ let url: URL
829
+ if isLocal {
830
+ url = URL(fileURLWithPath: effectiveUrlString)
831
+ } else {
832
+ guard let remoteUrl = URL(string: effectiveUrlString) else { continue }
833
+ url = remoteUrl
834
+ }
791
835
 
792
- let asset = AVURLAsset(url: url)
836
+ let asset = AVURLAsset(url: url, options: [
837
+ AVURLAssetPreferPreciseDurationAndTimingKey: true
838
+ ])
793
839
 
794
840
  // Preload essential keys for gapless playback
795
841
  asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
@@ -1007,10 +1053,12 @@ class TrackPlayerCore: NSObject {
1007
1053
  NitroPlayerLogger.log("TrackPlayerCore", "🔄 Removing \(existingPlayer.items().count) old items from player")
1008
1054
  existingPlayer.removeAllItems()
1009
1055
 
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 }
1056
+ // Lazy-load mode: if any track has no URL AND is not downloaded locally,
1057
+ // we can't create an AVPlayerItem for it and the queue order would be wrong.
1058
+ // Downloaded tracks with empty remote URLs still play from disk via getEffectiveUrl.
1059
+ let isLazyLoad = tracks.contains {
1060
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
1061
+ }
1014
1062
  if isLazyLoad {
1015
1063
  NitroPlayerLogger.log("TrackPlayerCore", "⏳ Lazy-load mode — player cleared, awaiting URL resolution")
1016
1064
  return
@@ -1718,12 +1766,12 @@ class TrackPlayerCore: NSObject {
1718
1766
  // Update currentTrackIndex BEFORE updating queue
1719
1767
  self.currentTrackIndex = index
1720
1768
 
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
1769
+ // Lazy-load guard: if the target track has no URL AND is not downloaded locally,
1770
+ // the queue can't be built. Defer to updateTracks once URL resolution completes.
1771
+ // Downloaded tracks play from disk via getEffectiveUrl — no remote URL needed.
1772
+ let targetTrack = fullPlaylist[index]
1773
+ let isLazyLoad = targetTrack.url.isEmpty
1774
+ && !DownloadManagerCore.shared.isTrackDownloaded(trackId: targetTrack.id)
1727
1775
  if isLazyLoad {
1728
1776
  NitroPlayerLogger.log("TrackPlayerCore", " ⏳ Lazy-load — deferring AVQueuePlayer setup; emitting track change for index \(index)")
1729
1777
  self.currentTracks = fullPlaylist
@@ -1855,17 +1903,26 @@ class TrackPlayerCore: NSObject {
1855
1903
  /**
1856
1904
  * Rebuild the AVQueuePlayer from current position with temporary tracks
1857
1905
  * Order: [current] + [playNext stack reversed] + [upNext queue] + [remaining original]
1906
+ *
1907
+ * - Parameter changedTrackIds: When non-nil, performs a **surgical** update:
1908
+ * only AVPlayerItems whose track ID is in this set are removed and re-created.
1909
+ * All other pre-buffered items are left in place and new items are inserted
1910
+ * around them. This preserves AVQueuePlayer's internal audio pre-roll buffers
1911
+ * for gapless inter-track transitions.
1912
+ * When nil, the queue is fully torn down and rebuilt (used by skip, reorder,
1913
+ * addToUpNext, playNext, etc.).
1858
1914
  */
1859
- private func rebuildAVQueueFromCurrentPosition() {
1915
+ private func rebuildAVQueueFromCurrentPosition(changedTrackIds: Set<String>? = nil) {
1860
1916
  guard let player = self.player else { return }
1861
1917
 
1862
1918
  let currentItem = player.currentItem
1863
1919
  let playingItems = player.items()
1864
1920
 
1921
+ // ---- Build the desired upcoming track list ----
1922
+
1865
1923
  var newQueueTracks: [TrackItem] = []
1866
1924
 
1867
1925
  // Add playNext stack (LIFO - most recently added plays first)
1868
- // Skip index 0 if current track is from playNext (it's already playing)
1869
1926
  if currentTemporaryType == .playNext && playNextStack.count > 1 {
1870
1927
  newQueueTracks.append(contentsOf: playNextStack.dropFirst())
1871
1928
  } else if currentTemporaryType != .playNext {
@@ -1873,7 +1930,6 @@ class TrackPlayerCore: NSObject {
1873
1930
  }
1874
1931
 
1875
1932
  // Add upNext queue (in order, FIFO)
1876
- // Skip index 0 if current track is from upNext (it's already playing)
1877
1933
  if currentTemporaryType == .upNext && upNextQueue.count > 1 {
1878
1934
  newQueueTracks.append(contentsOf: upNextQueue.dropFirst())
1879
1935
  } else if currentTemporaryType != .upNext {
@@ -1885,12 +1941,84 @@ class TrackPlayerCore: NSObject {
1885
1941
  newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
1886
1942
  }
1887
1943
 
1888
- // Remove all items from player EXCEPT the currently playing one
1944
+ // ---- Collect existing upcoming AVPlayerItems ----
1945
+
1946
+ let upcomingItems: [AVPlayerItem]
1947
+ if let ci = currentItem, let ciIndex = playingItems.firstIndex(of: ci) {
1948
+ upcomingItems = Array(playingItems.suffix(from: playingItems.index(after: ciIndex)))
1949
+ } else {
1950
+ upcomingItems = []
1951
+ }
1952
+
1953
+ let existingIds = upcomingItems.compactMap { $0.trackId }
1954
+ let desiredIds = newQueueTracks.map { $0.id }
1955
+
1956
+ // ---- Fast-path: nothing to do if queue already matches ----
1957
+
1958
+ if existingIds == desiredIds {
1959
+ if let changedIds = changedTrackIds {
1960
+ if Set(existingIds).isDisjoint(with: changedIds) {
1961
+ NitroPlayerLogger.log("TrackPlayerCore",
1962
+ "✅ Queue matches & no buffered URLs changed — preserving \(existingIds.count) items for gapless")
1963
+ return
1964
+ }
1965
+ } else {
1966
+ NitroPlayerLogger.log("TrackPlayerCore",
1967
+ "✅ Queue already matches desired order — preserving \(existingIds.count) items for gapless")
1968
+ return
1969
+ }
1970
+ }
1971
+
1972
+ // ---- Surgical path (changedTrackIds provided, e.g. from updateTracks) ----
1973
+ // Only remove items whose URLs actually changed; insert newly-resolved items
1974
+ // in the correct positions around existing, pre-buffered items.
1975
+
1976
+ if let changedIds = changedTrackIds {
1977
+ // Build lookup of reusable (un-changed) items by track ID
1978
+ var reusableByTrackId: [String: AVPlayerItem] = [:]
1979
+ for item in upcomingItems {
1980
+ if let trackId = item.trackId, !changedIds.contains(trackId) {
1981
+ reusableByTrackId[trackId] = item
1982
+ }
1983
+ }
1984
+
1985
+ // Remove only items whose URLs changed
1986
+ let desiredIdSet = Set(desiredIds)
1987
+ for item in upcomingItems {
1988
+ guard let trackId = item.trackId else { continue }
1989
+ if changedIds.contains(trackId) || !desiredIdSet.contains(trackId) {
1990
+ player.remove(item)
1991
+ }
1992
+ }
1993
+
1994
+ // Walk through the desired order, inserting new items around the
1995
+ // reusable items that are still sitting in the queue untouched.
1996
+ var lastAnchor: AVPlayerItem? = currentItem
1997
+ for trackId in desiredIds {
1998
+ if let reusable = reusableByTrackId[trackId] {
1999
+ // Item is still in the queue at its original position — advance anchor
2000
+ lastAnchor = reusable
2001
+ } else if let track = newQueueTracks.first(where: { $0.id == trackId }),
2002
+ let newItem = createGaplessPlayerItem(for: track, isPreload: false)
2003
+ {
2004
+ player.insert(newItem, after: lastAnchor)
2005
+ lastAnchor = newItem
2006
+ }
2007
+ }
2008
+
2009
+ let preserved = reusableByTrackId.count
2010
+ let inserted = desiredIds.count - preserved
2011
+ NitroPlayerLogger.log("TrackPlayerCore",
2012
+ "🔄 Surgical rebuild: preserved \(preserved) buffered items, inserted \(inserted) new items")
2013
+ return
2014
+ }
2015
+
2016
+ // ---- Full rebuild path (no changedTrackIds — skip, reorder, etc.) ----
2017
+
1889
2018
  for item in playingItems where item != currentItem {
1890
2019
  player.remove(item)
1891
2020
  }
1892
2021
 
1893
- // Insert new items in order
1894
2022
  var lastItem = currentItem
1895
2023
  for track in newQueueTracks {
1896
2024
  if let item = createGaplessPlayerItem(for: track, isPreload: false) {
@@ -1898,7 +2026,6 @@ class TrackPlayerCore: NSObject {
1898
2026
  lastItem = item
1899
2027
  }
1900
2028
  }
1901
-
1902
2029
  }
1903
2030
 
1904
2031
  /**
@@ -1960,7 +2087,11 @@ class TrackPlayerCore: NSObject {
1960
2087
  // Get current track to decide how to handle it
1961
2088
  let currentTrack = self.getCurrentTrack()
1962
2089
  let currentTrackId = currentTrack?.id
1963
- let currentTrackIsEmpty = currentTrack?.url.isEmpty ?? false
2090
+ // A track is only "empty" if it has no remote URL AND is not downloaded.
2091
+ // Downloaded tracks with empty .url are playing from disk — don't replace them.
2092
+ let currentTrackIsEmpty = currentTrack.map {
2093
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2094
+ } ?? false
1964
2095
 
1965
2096
  // Filter out current track and validate
1966
2097
  let safeTracks = tracks.filter { track in
@@ -2047,7 +2178,9 @@ class TrackPlayerCore: NSObject {
2047
2178
  self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
2048
2179
  } else {
2049
2180
  // A current AVPlayerItem already exists — preserve it and only rebuild upcoming items.
2050
- self.rebuildAVQueueFromCurrentPosition()
2181
+ // Pass the set of track IDs whose URLs actually changed so the rebuild
2182
+ // can keep already-buffered items intact for gapless transitions.
2183
+ self.rebuildAVQueueFromCurrentPosition(changedTrackIds: updatedTrackIds)
2051
2184
  // Re-preload upcoming tracks for gapless playback
2052
2185
  // CRITICAL: This restores gapless buffering after queue rebuild
2053
2186
  self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
@@ -2099,7 +2232,11 @@ class TrackPlayerCore: NSObject {
2099
2232
  return []
2100
2233
  }
2101
2234
 
2102
- return playlist.tracks.filter { $0.url.isEmpty }
2235
+ // Only return tracks that truly can't play: empty remote URL AND not
2236
+ // downloaded locally. Downloaded tracks play from disk via getEffectiveUrl.
2237
+ return playlist.tracks.filter {
2238
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2239
+ }
2103
2240
  }
2104
2241
 
2105
2242
  /**
@@ -2196,12 +2333,18 @@ class TrackPlayerCore: NSObject {
2196
2333
  private func checkUpcomingTracksForUrls(lookahead: Int = 5) {
2197
2334
  let upcomingTracks = getNextTracksInternal(count: lookahead)
2198
2335
 
2199
- // Always include the current track if it has no URL — it can't play without one
2336
+ // Always include the current track if it has no URL and isn't downloaded — it can't play without one
2200
2337
  let currentTrack = getCurrentTrack()
2201
- let currentNeedsUrl = currentTrack.map { $0.url.isEmpty } ?? false
2338
+ let currentNeedsUrl = currentTrack.map {
2339
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2340
+ } ?? false
2202
2341
  let candidateTracks = currentNeedsUrl ? [currentTrack!] + upcomingTracks : upcomingTracks
2203
2342
 
2204
- let tracksNeedingUrls = candidateTracks.filter { $0.url.isEmpty }
2343
+ // Only request URLs for tracks that truly can't play: empty remote URL
2344
+ // AND not downloaded locally (downloaded tracks play from disk via getEffectiveUrl).
2345
+ let tracksNeedingUrls = candidateTracks.filter {
2346
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
2347
+ }
2205
2348
 
2206
2349
  if !tracksNeedingUrls.isEmpty {
2207
2350
  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.5.9-alpha.1",
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,