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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
boundaryTimeObserver = player.
|
|
244
|
-
[weak self] in
|
|
245
|
-
|
|
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", "⏱️
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
let isLazyLoad = tracks.contains {
|
|
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
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|
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,
|