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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
boundaryTimeObserver = player.
|
|
244
|
-
[weak self] in
|
|
245
|
-
|
|
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", "⏱️
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
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
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
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
|
-
|
|
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
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
let isLazyLoad = tracks.contains {
|
|
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
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
170
|
+
var tap: MTAudioProcessingTap?
|
|
171
|
+
let createStatus = MTAudioProcessingTapCreate(
|
|
172
|
+
kCFAllocatorDefault,
|
|
173
|
+
&callbacks,
|
|
174
|
+
kMTAudioProcessingTapCreationFlag_PreEffects,
|
|
175
|
+
&tap
|
|
176
|
+
)
|
|
147
177
|
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
let audioMix = AVMutableAudioMix()
|
|
152
|
-
audioMix.inputParameters = [inputParams]
|
|
183
|
+
inputParams.audioTapProcessor = audioTap
|
|
153
184
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
package/lib/hooks/usePlaylist.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
142
|
+
refreshOnTrackChange();
|
|
122
143
|
}
|
|
123
144
|
});
|
|
124
145
|
return () => {
|
|
125
146
|
unsubscribe();
|
|
126
147
|
};
|
|
127
|
-
}, [
|
|
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.
|
|
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",
|
package/src/hooks/usePlaylist.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
166
|
+
refreshOnTrackChange()
|
|
145
167
|
}
|
|
146
168
|
})
|
|
147
169
|
|
|
148
170
|
return () => {
|
|
149
171
|
unsubscribe()
|
|
150
172
|
}
|
|
151
|
-
}, [
|
|
173
|
+
}, [refreshOnTrackChange])
|
|
152
174
|
|
|
153
175
|
return {
|
|
154
176
|
currentPlaylist,
|