react-native-nitro-player 1.5.0 → 1.5.1-alpha.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.
Files changed (33) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +71 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +82 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  12. package/ios/HybridTrackPlayer.swift +52 -29
  13. package/ios/core/TrackPlayerCore.swift +9 -1
  14. package/ios/core/TrackPlayerListener.swift +20 -5
  15. package/ios/core/TrackPlayerNotify.swift +9 -7
  16. package/ios/core/TrackPlayerPlayback.swift +75 -39
  17. package/ios/core/TrackPlayerQueue.swift +28 -4
  18. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  19. package/ios/core/TrackPlayerRecovery.swift +1 -1
  20. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  21. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  22. package/ios/download/DownloadDatabase.swift +41 -13
  23. package/ios/media/MediaSessionManager.swift +19 -12
  24. package/ios/queue/HybridPlayerQueue.swift +10 -5
  25. package/lib/hooks/callbackManager.d.ts +17 -0
  26. package/lib/hooks/callbackManager.js +67 -1
  27. package/lib/hooks/useActualQueue.js +18 -12
  28. package/lib/hooks/useNowPlaying.js +10 -2
  29. package/package.json +1 -1
  30. package/src/components/CastButton.tsx +14 -2
  31. package/src/hooks/callbackManager.ts +87 -1
  32. package/src/hooks/useActualQueue.ts +18 -13
  33. package/src/hooks/useNowPlaying.ts +12 -2
@@ -292,7 +292,8 @@ extension TrackPlayerCore {
292
292
  upNextQueue.removeAll()
293
293
  currentTemporaryType = .none
294
294
 
295
- let allItems = currentTracks.compactMap { createGaplessPlayerItem(for: $0, isPreload: false) }
295
+ let windowed = currentTracks.prefix(1 + Constants.queueWindowSize)
296
+ let allItems = windowed.compactMap { createGaplessPlayerItem(for: $0, isPreload: false) }
296
297
  var lastItem: AVPlayerItem? = nil
297
298
  for item in allItems {
298
299
  player.insert(item, after: lastItem)
@@ -399,6 +400,15 @@ extension TrackPlayerCore {
399
400
  setupBoundaryTimeObserver()
400
401
  }
401
402
 
403
+ // Top up the windowed AVQueuePlayer — the item we just consumed freed a slot.
404
+ // Only when it is actually short: rebuildAVQueueFromCurrentPosition can fall into
405
+ // rebuildQueueFromPlaylistIndex (current track no longer in the playlist), which
406
+ // replaces the current item and re-enters this callback. The length check makes any
407
+ // such re-entry a no-op and terminate.
408
+ if player.items().count - 1 < Constants.queueWindowSize {
409
+ rebuildAVQueueFromCurrentPosition()
410
+ }
411
+
402
412
  // Preload upcoming tracks for gapless playback
403
413
  preloadUpcomingTracks(from: currentTrackIndex + 1)
404
414
  cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
@@ -421,11 +431,12 @@ extension TrackPlayerCore {
421
431
  self?.player?.automaticallyWaitsToMinimizeStalling = false
422
432
  // Update now playing info now that duration is available (capture on playerQueue first)
423
433
  let state = self?.getStateInternal()
424
- let queue = self?.getActualQueueInternal() ?? []
434
+ let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
425
435
  let track = self?.getCurrentTrack()
426
436
  DispatchQueue.main.async {
427
437
  if let track = track, let state = state {
428
- self?.mediaSessionManager?.updateFromPlayerQueue(track: track, state: state, queue: queue)
438
+ self?.mediaSessionManager?.updateFromPlayerQueue(
439
+ track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
429
440
  }
430
441
  }
431
442
  } else if item.status == .failed {
@@ -438,9 +449,13 @@ extension TrackPlayerCore {
438
449
  currentItemObservers.append(statusObserver)
439
450
 
440
451
  let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in
441
- if item.isPlaybackBufferEmpty {
452
+ guard item.isPlaybackBufferEmpty else { return }
453
+ // KVO fires on an arbitrary thread — emitStateChange reads playerQueue-owned
454
+ // state (currentTracks, temp queues, isRecoveringFromStall), so hop first.
455
+ self?.playerQueue.async {
456
+ guard let self, self.player?.currentItem === item else { return }
442
457
  NitroPlayerLogger.log("TrackPlayerCore", "⏸️ Buffer empty (buffering)")
443
- self?.emitStateChange()
458
+ self.emitStateChange()
444
459
  }
445
460
  }
446
461
  currentItemObservers.append(bufferEmptyObserver)
@@ -13,22 +13,24 @@ extension TrackPlayerCore {
13
13
  // Called on playerQueue — invoke listeners directly (Nitro handles JS thread hop)
14
14
  func notifyTrackChange(_ track: TrackItem, _ reason: Reason?) {
15
15
  onChangeTrackListeners.forEach { $0(track, reason) }
16
- // Capture state + queue now (on playerQueue), pass pre-computed values to main
16
+ // Capture state + queue metrics now (on playerQueue), pass pre-computed values to main.
17
+ // Metrics, not the queue itself — the media session only needs count and position.
17
18
  let state = getStateInternal()
18
- let queue = getActualQueueInternal()
19
+ let metrics = actualQueueMetrics()
19
20
  DispatchQueue.main.async { [weak self] in
20
- self?.mediaSessionManager?.updateFromPlayerQueue(track: track, state: state, queue: queue)
21
+ self?.mediaSessionManager?.updateFromPlayerQueue(
22
+ track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
21
23
  }
22
24
  }
23
25
 
24
26
  func notifyPlaybackStateChange(_ state: TrackPlayerState, _ reason: Reason?) {
25
27
  onPlaybackStateChangeListeners.forEach { $0(state, reason) }
28
+ guard let track = getCurrentTrack() else { return }
26
29
  let playerState = getStateInternal()
27
- let queue = getActualQueueInternal()
28
- let track = getCurrentTrack()
30
+ let metrics = actualQueueMetrics()
29
31
  DispatchQueue.main.async { [weak self] in
30
- guard let track = track else { return }
31
- self?.mediaSessionManager?.updateFromPlayerQueue(track: track, state: playerState, queue: queue)
32
+ self?.mediaSessionManager?.updateFromPlayerQueue(
33
+ track: track, state: playerState, queueCount: metrics.count, positionInQueue: metrics.position)
32
34
  }
33
35
  }
34
36
 
@@ -11,78 +11,98 @@ import MediaPlayer
11
11
 
12
12
  extension TrackPlayerCore {
13
13
 
14
- func play() async {
14
+ // MARK: - Public commands
15
+ //
16
+ // Each command has a `…OnQueue` form that MUST run on `playerQueue` and an `async`
17
+ // shim for callers that are not already there (media-session remote commands).
18
+ // HybridTrackPlayer dispatches the `…OnQueue` form directly from the JS thread so
19
+ // the serial queue's FIFO order equals the JS call order — see HybridTrackPlayer.
20
+ //
21
+ // Reading `isCasting` inside the queue block (not before it) keeps every cast-vs-local
22
+ // decision on the thread that owns that flag.
23
+
24
+ func playOnQueue() {
15
25
  if isCasting {
16
26
  intendedToPlay = true
17
27
  castManager?.play()
18
28
  return
19
29
  }
20
- await withPlayerQueueNoThrow { self.playInternal() }
30
+ playInternal()
21
31
  }
32
+ func play() async { await withPlayerQueueNoThrow { self.playOnQueue() } }
22
33
 
23
- func pause() async {
34
+ func pauseOnQueue() {
24
35
  if isCasting {
25
36
  intendedToPlay = false
26
37
  castManager?.pause()
27
38
  return
28
39
  }
29
- await withPlayerQueueNoThrow { self.pauseInternal() }
40
+ pauseInternal()
30
41
  }
42
+ func pause() async { await withPlayerQueueNoThrow { self.pauseOnQueue() } }
31
43
 
32
- func seek(position: Double) async {
44
+ func seekOnQueue(position: Double) {
33
45
  if isCasting {
34
46
  castManager?.seek(to: position)
35
47
  return
36
48
  }
37
- await withPlayerQueueNoThrow { self.seekInternal(position: position) }
49
+ seekInternal(position: position)
50
+ }
51
+ func seek(position: Double) async {
52
+ await withPlayerQueueNoThrow { self.seekOnQueue(position: position) }
38
53
  }
39
54
 
40
- func skipToNext() async {
55
+ func skipToNextOnQueue() {
41
56
  if isCasting {
42
57
  castManager?.skipToNext()
43
58
  return
44
59
  }
45
- await withPlayerQueueNoThrow { self.skipToNextInternal() }
60
+ skipToNextInternal()
46
61
  }
62
+ func skipToNext() async { await withPlayerQueueNoThrow { self.skipToNextOnQueue() } }
47
63
 
48
- func skipToPrevious() async {
64
+ func skipToPreviousOnQueue() {
49
65
  if isCasting {
50
66
  // The receiver queue starts at the current track, so queuePreviousItem has nothing behind it — use core state instead.
51
- await withPlayerQueueNoThrow { self.skipToPreviousCastInternal() }
67
+ skipToPreviousCastInternal()
52
68
  return
53
69
  }
54
- await withPlayerQueueNoThrow { self.skipToPreviousInternal() }
70
+ skipToPreviousInternal()
55
71
  }
72
+ func skipToPrevious() async { await withPlayerQueueNoThrow { self.skipToPreviousOnQueue() } }
56
73
 
74
+ func setRepeatModeOnQueue(mode: RepeatMode) {
75
+ currentRepeatMode = mode
76
+ player?.actionAtItemEnd = (mode == .track) ? .none : .advance
77
+ if isCasting { castManager?.setQueueRepeatMode(mode) }
78
+ NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
79
+ }
57
80
  func setRepeatMode(mode: RepeatMode) async {
58
- await withPlayerQueueNoThrow {
59
- self.currentRepeatMode = mode
60
- self.player?.actionAtItemEnd = (mode == .track) ? .none : .advance
61
- if self.isCasting { self.castManager?.setQueueRepeatMode(mode) }
62
- NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
63
- }
81
+ await withPlayerQueueNoThrow { self.setRepeatModeOnQueue(mode: mode) }
64
82
  }
65
83
 
66
- func setVolume(volume: Double) async {
84
+ func setVolumeOnQueue(volume: Double) {
85
+ let clamped = max(0.0, min(100.0, volume))
67
86
  if isCasting {
68
- let clamped = max(0.0, min(100.0, volume))
69
87
  castManager?.setVolume(Float(clamped / 100.0))
70
88
  return
71
89
  }
72
- await withPlayerQueueNoThrow {
73
- let clamped = max(0.0, min(100.0, volume))
74
- let normalized = Float(clamped / 100.0)
75
- self.player?.volume = normalized
76
- NitroPlayerLogger.log("TrackPlayerCore", "🔊 Volume set to \(Int(clamped))% (normalized: \(normalized))")
77
- }
90
+ let normalized = Float(clamped / 100.0)
91
+ player?.volume = normalized
92
+ NitroPlayerLogger.log(
93
+ "TrackPlayerCore", "🔊 Volume set to \(Int(clamped))% (normalized: \(normalized))")
94
+ }
95
+ func setVolume(volume: Double) async {
96
+ await withPlayerQueueNoThrow { self.setVolumeOnQueue(volume: volume) }
78
97
  }
79
98
 
80
- func configure(androidAutoEnabled: Bool?, carPlayEnabled: Bool?, showInNotification: Bool?, lookaheadCount: Int?) async {
81
- await withPlayerQueueNoThrow {
82
- if let la = lookaheadCount {
83
- self.lookaheadCount = la
84
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(la)")
85
- }
99
+ func configureOnQueue(
100
+ androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
101
+ showInNotification: Bool?, lookaheadCount: Int?
102
+ ) {
103
+ if let la = lookaheadCount {
104
+ self.lookaheadCount = la
105
+ NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(la)")
86
106
  }
87
107
  DispatchQueue.main.async { [weak self] in
88
108
  self?.mediaSessionManager?.configure(
@@ -92,21 +112,31 @@ extension TrackPlayerCore {
92
112
  )
93
113
  }
94
114
  }
115
+ func configure(
116
+ androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
117
+ showInNotification: Bool?, lookaheadCount: Int?
118
+ ) async {
119
+ await withPlayerQueueNoThrow {
120
+ self.configureOnQueue(
121
+ androidAutoEnabled: androidAutoEnabled, carPlayEnabled: carPlayEnabled,
122
+ showInNotification: showInNotification, lookaheadCount: lookaheadCount)
123
+ }
124
+ }
95
125
 
96
- func setPlaybackSpeed(_ speed: Double) async {
126
+ func setPlaybackSpeedOnQueue(_ speed: Double) {
127
+ currentPlaybackSpeed = speed
97
128
  if isCasting {
98
- currentPlaybackSpeed = speed
99
129
  castManager?.setPlaybackRate(Float(speed))
100
130
  return
101
131
  }
102
- await withPlayerQueueNoThrow {
103
- self.currentPlaybackSpeed = speed
104
- // Only update rate if currently playing; pause keeps rate at 0 until play() is called
105
- if let player = self.player, player.rate != 0 {
106
- player.rate = Float(speed)
107
- }
132
+ // Only update rate if currently playing; pause keeps rate at 0 until play() is called
133
+ if let player = self.player, player.rate != 0 {
134
+ player.rate = Float(speed)
108
135
  }
109
136
  }
137
+ func setPlaybackSpeed(_ speed: Double) async {
138
+ await withPlayerQueueNoThrow { self.setPlaybackSpeedOnQueue(speed) }
139
+ }
110
140
 
111
141
  func getPlaybackSpeed() async -> Double {
112
142
  await withPlayerQueueNoThrow { self.currentPlaybackSpeed }
@@ -202,6 +232,12 @@ extension TrackPlayerCore {
202
232
  }
203
233
  }
204
234
 
235
+ // The AVQueuePlayer is windowed, so items().count is not the logical queue length.
236
+ // Top up first so there is always something to advance to while tracks remain.
237
+ if hasUpcomingTrack() {
238
+ if queuePlayer.items().count <= 1 { rebuildAVQueueFromCurrentPosition() }
239
+ }
240
+
205
241
  if queuePlayer.items().count > 1 {
206
242
  queuePlayer.advanceToNextItem()
207
243
  } else {
@@ -18,11 +18,13 @@ extension TrackPlayerCore {
18
18
  await withPlayerQueueNoThrow { self.getActualQueueInternal() }
19
19
  }
20
20
 
21
+ @discardableResult
22
+ func skipToIndexOnQueue(index: Int) -> Bool {
23
+ isCasting ? skipToIndexCastInternal(index: index) : skipToIndexInternal(index: index)
24
+ }
25
+
21
26
  func skipToIndex(index: Int) async -> Bool {
22
- if isCasting {
23
- return await withPlayerQueueNoThrow { self.skipToIndexCastInternal(index: index) }
24
- }
25
- return await withPlayerQueueNoThrow { self.skipToIndexInternal(index: index) }
27
+ await withPlayerQueueNoThrow { self.skipToIndexOnQueue(index: index) }
26
28
  }
27
29
 
28
30
  func playFromIndex(index: Int) async {
@@ -144,6 +146,28 @@ extension TrackPlayerCore {
144
146
  return queue
145
147
  }
146
148
 
149
+ /// Length of the logical queue and the current track's position in it, derived
150
+ /// arithmetically. `getActualQueueInternal()` copies every TrackItem to answer the
151
+ /// same two questions — too expensive to run on every playback-state change.
152
+ func actualQueueMetrics() -> (count: Int, position: Int) {
153
+ let before: Int
154
+ if currentTemporaryType != .none {
155
+ before = min(currentTrackIndex + 1, currentTracks.count)
156
+ } else {
157
+ before = max(0, currentTrackIndex)
158
+ }
159
+ let hasCurrent = getCurrentTrack() != nil
160
+ let playNextCount =
161
+ currentTemporaryType == .playNext ? max(0, playNextStack.count - 1) : playNextStack.count
162
+ let upNextCount =
163
+ currentTemporaryType == .upNext ? max(0, upNextQueue.count - 1) : upNextQueue.count
164
+ let after =
165
+ currentTrackIndex + 1 < currentTracks.count ? currentTracks.count - (currentTrackIndex + 1) : 0
166
+
167
+ let count = before + (hasCurrent ? 1 : 0) + playNextCount + upNextCount + after
168
+ return (count, hasCurrent ? before : -1)
169
+ }
170
+
147
171
  func getCurrentTrack() -> TrackItem? {
148
172
  if currentTemporaryType != .none, let trackId = activeCurrentTrackId {
149
173
  if currentTemporaryType == .playNext { return playNextStack.first(where: { $0.id == trackId }) }
@@ -11,6 +11,34 @@ import Foundation
11
11
 
12
12
  extension TrackPlayerCore {
13
13
 
14
+ /// Cancels an item's outstanding asset loading before it is released.
15
+ ///
16
+ /// `AVURLAsset.dealloc` blocks the deallocating thread inside `URLAssetFinalize`
17
+ /// until any in-flight `loadValuesAsynchronously` / resource-loader work finishes —
18
+ /// and AVPlayer tears removed items down on the MAIN thread. Dropping items without
19
+ /// cancelling first therefore stalls the main run loop (and with it RCTTiming, so
20
+ /// JS timers stop firing) for as long as the pending loads take.
21
+ /// `cancelLoading()` itself waits for in-flight work to unwind, so it is only worth
22
+ /// paying on a full teardown (playlist switch / jump), never on the incremental
23
+ /// window rebuild that runs on every playNext / addToUpNext.
24
+ ///
25
+ /// A cancelled asset must never be handed back out: `createGaplessPlayerItem` reuses
26
+ /// `preloadedAssets`, and reusing a cancelled one yields an item that fails and then
27
+ /// burns the failed-item retry budget. Evict it instead.
28
+ func cancelLoading(of item: AVPlayerItem) {
29
+ guard let asset = item.asset as? AVURLAsset else { return }
30
+ if let trackId = item.trackId, preloadedAssets[trackId] === asset {
31
+ preloadedAssets.removeValue(forKey: trackId)
32
+ }
33
+ asset.cancelLoading()
34
+ }
35
+
36
+ /// Removes every item from the player, cancelling their asset loads first.
37
+ func removeAllItemsCancellingLoads(_ player: AVQueuePlayer) {
38
+ for item in player.items() { cancelLoading(of: item) }
39
+ player.removeAllItems()
40
+ }
41
+
14
42
  func updatePlayerQueue(tracks: [TrackItem]) {
15
43
  // While casting, mirror the new queue to the Cast device instead of building
16
44
  // the local AVQueuePlayer (which would start local audio).
@@ -55,6 +83,7 @@ extension TrackPlayerCore {
55
83
  player?.automaticallyWaitsToMinimizeStalling = true
56
84
 
57
85
  // Clear old preloaded assets when loading new queue
86
+ for asset in preloadedAssets.values { asset.cancelLoading() }
58
87
  preloadedAssets.removeAll()
59
88
 
60
89
  guard let existingPlayer = self.player else {
@@ -63,7 +92,7 @@ extension TrackPlayerCore {
63
92
  }
64
93
 
65
94
  NitroPlayerLogger.log("TrackPlayerCore", "🔄 Removing \(existingPlayer.items().count) old items from player")
66
- existingPlayer.removeAllItems()
95
+ removeAllItemsCancellingLoads(existingPlayer)
67
96
 
68
97
  // Lazy-load mode if the first track needs a URL. Tracks further in the queue with
69
98
  // empty URLs are dropped safely by compactMap below and resolved via onTracksNeedUpdate.
@@ -75,7 +104,9 @@ extension TrackPlayerCore {
75
104
  return
76
105
  }
77
106
 
78
- let items = tracks.enumerated().compactMap { (index, track) -> AVPlayerItem? in
107
+ // Only materialize a window of items — the rest are added as playback advances.
108
+ let windowed = tracks.prefix(1 + Constants.queueWindowSize)
109
+ let items = windowed.enumerated().compactMap { (index, track) -> AVPlayerItem? in
79
110
  let isPreload = index < Constants.gaplessPreloadCount
80
111
  return createGaplessPlayerItem(for: track, isPreload: isPreload)
81
112
  }
@@ -182,7 +213,7 @@ extension TrackPlayerCore {
182
213
  player?.removeTimeObserver(boundaryObserver)
183
214
  self.boundaryTimeObserver = nil
184
215
  }
185
- player?.removeAllItems()
216
+ if let p = player { removeAllItemsCancellingLoads(p) }
186
217
  self.currentTracks = fullPlaylist
187
218
  if let track = self.currentTracks[safe: index] {
188
219
  notifyTrackChange(track, .skip)
@@ -190,7 +221,8 @@ extension TrackPlayerCore {
190
221
  return true
191
222
  }
192
223
 
193
- let tracksToPlay = Array(fullPlaylist[index...])
224
+ let windowEnd = min(index + 1 + Constants.queueWindowSize, fullPlaylist.count)
225
+ let tracksToPlay = Array(fullPlaylist[index..<windowEnd])
194
226
  NitroPlayerLogger.log("TrackPlayerCore", " 🔄 Creating gapless queue with \(tracksToPlay.count) tracks starting from index \(index)")
195
227
 
196
228
  let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
@@ -212,7 +244,7 @@ extension TrackPlayerCore {
212
244
  // Re-enable stall waiting for the new first track
213
245
  player.automaticallyWaitsToMinimizeStalling = true
214
246
 
215
- player.removeAllItems()
247
+ removeAllItemsCancellingLoads(player)
216
248
  var lastItem: AVPlayerItem? = nil
217
249
  for item in items {
218
250
  player.insert(item, after: lastItem)
@@ -308,6 +340,11 @@ extension TrackPlayerCore {
308
340
  newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
309
341
  }
310
342
 
343
+ // Window the materialized queue — currentItemDidChange tops it up on every transition.
344
+ if newQueueTracks.count > Constants.queueWindowSize {
345
+ newQueueTracks.removeSubrange(Constants.queueWindowSize...)
346
+ }
347
+
311
348
  // Collect existing upcoming AVPlayerItems
312
349
  let upcomingItems: [AVPlayerItem]
313
350
  if let ci = currentItem, let ciIndex = playingItems.firstIndex(of: ci) {
@@ -370,19 +407,47 @@ extension TrackPlayerCore {
370
407
  return
371
408
  }
372
409
 
373
- // Full rebuild path (no changedTrackIds — skip, reorder, etc.)
374
- for item in playingItems where item != currentItem {
410
+ // Incremental path (no changedTrackIds — skip, reorder, window top-up).
411
+ // Keep the longest matching prefix of already-buffered items and only rebuild
412
+ // from the first divergence. A pure append (the common window top-up) therefore
413
+ // touches nothing that is already buffered, preserving gapless transitions.
414
+ var commonPrefix = 0
415
+ while commonPrefix < upcomingItems.count,
416
+ commonPrefix < newQueueTracks.count,
417
+ upcomingItems[commonPrefix].trackId == newQueueTracks[commonPrefix].id
418
+ {
419
+ commonPrefix += 1
420
+ }
421
+
422
+ for item in upcomingItems[commonPrefix...] {
375
423
  player.remove(item)
376
424
  }
377
425
 
378
- var lastItem = currentItem
379
- for (offset, track) in newQueueTracks.enumerated() {
426
+ var lastItem: AVPlayerItem? = commonPrefix > 0 ? upcomingItems[commonPrefix - 1] : currentItem
427
+ for offset in commonPrefix..<newQueueTracks.count {
380
428
  let isPreload = offset < Constants.gaplessPreloadCount
381
- if let item = createGaplessPlayerItem(for: track, isPreload: isPreload) {
429
+ if let item = createGaplessPlayerItem(for: newQueueTracks[offset], isPreload: isPreload) {
382
430
  player.insert(item, after: lastItem)
383
431
  lastItem = item
384
432
  }
385
433
  }
434
+
435
+ let created = newQueueTracks.count - commonPrefix
436
+ NitroPlayerLogger.log(
437
+ "TrackPlayerCore",
438
+ "🔄 Incremental rebuild: kept \(commonPrefix) buffered items, created \(created)")
439
+ }
440
+
441
+ /// True when the logical queue has a track after the current one, regardless of
442
+ /// how much of it is currently materialized into the AVQueuePlayer.
443
+ func hasUpcomingTrack() -> Bool {
444
+ let pendingPlayNext =
445
+ currentTemporaryType == .playNext ? max(0, playNextStack.count - 1) : playNextStack.count
446
+ if pendingPlayNext > 0 { return true }
447
+ let pendingUpNext =
448
+ currentTemporaryType == .upNext ? max(0, upNextQueue.count - 1) : upNextQueue.count
449
+ if pendingUpNext > 0 { return true }
450
+ return currentTrackIndex + 1 < currentTracks.count
386
451
  }
387
452
 
388
453
  /// Extracts custom HTTP headers (e.g. `Authorization`) from `extraPayload.headers`.
@@ -479,46 +544,40 @@ extension TrackPlayerCore {
479
544
  item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
480
545
  item.trackId = track.id
481
546
 
482
- if isPreload {
483
- asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
484
- var allKeysLoaded = true
485
- for key in Constants.preloadAssetKeys {
486
- var error: NSError?
487
- let status = asset.statusOfValue(forKey: key, error: &error)
488
- if status == .failed {
489
- NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Failed to load key '\(key)' for \(track.title): \(error?.localizedDescription ?? "unknown")")
490
- allKeysLoaded = false
491
- }
492
- }
493
- if allKeysLoaded {
494
- NitroPlayerLogger.log("TrackPlayerCore", "✅ All asset keys preloaded for \(track.title)")
495
- }
496
- // "tracks" key is now loaded — EQ tap attaches synchronously
497
- EqualizerCore.shared.applyAudioMix(to: item)
498
- }
499
- } else {
500
- EqualizerCore.shared.applyAudioMix(to: item)
501
- }
547
+ // Deliberately NOT calling loadValuesAsynchronously here.
548
+ //
549
+ // AVURLAsset.dealloc blocks its thread inside URLAssetFinalize until any in-flight
550
+ // key load finishes — and AVPlayer tears removed items down on the MAIN thread, so
551
+ // an item discarded mid-load stalls the main run loop (and RCTTiming with it, which
552
+ // stops JS timers). Asset warm-up still happens in preloadUpcomingTracks, which only
553
+ // publishes assets into `preloadedAssets` once every key has finished loading, so a
554
+ // reused preloaded asset has nothing in flight either.
555
+ //
556
+ // Caveat: with the equalizer ENABLED, applyAudioMix still loads the "tracks" key
557
+ // asynchronously for assets that don't have it yet, so a small window remains. That
558
+ // is unchanged from before (it was already the non-preload path) and is strictly
559
+ // less in-flight work than the previous 4-key preload on every queued item.
560
+ EqualizerCore.shared.applyAudioMix(to: item)
502
561
 
503
562
  return item
504
563
  }
505
564
 
506
565
  /// Preloads assets for upcoming tracks to enable gapless playback
507
566
  func preloadUpcomingTracks(from startIndex: Int) {
508
- // Capture the set of track IDs that already have AVPlayerItems in the queue.
567
+ // Snapshot every piece of playerQueue-owned state HERE (we are on playerQueue).
568
+ // `preloadQueue` must never touch currentTracks / preloadedAssets directly —
569
+ // Swift Array/Dictionary are not safe for concurrent access.
509
570
  let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
571
+ let alreadyPreloaded = Set(preloadedAssets.keys)
572
+ let endIndex = min(startIndex + Constants.gaplessPreloadCount, currentTracks.count)
573
+ guard startIndex >= 0, startIndex < endIndex else { return }
574
+ let candidates = Array(currentTracks[startIndex..<endIndex])
510
575
 
511
576
  preloadQueue.async { [weak self] in
512
577
  guard let self else { return }
513
578
 
514
- let tracks = self.currentTracks
515
- let endIndex = min(startIndex + Constants.gaplessPreloadCount, tracks.count)
516
-
517
- for i in startIndex..<endIndex {
518
- guard i < tracks.count else { break }
519
- let track = tracks[i]
520
-
521
- if self.preloadedAssets[track.id] != nil || queuedTrackIds.contains(track.id) {
579
+ for track in candidates {
580
+ if alreadyPreloaded.contains(track.id) || queuedTrackIds.contains(track.id) {
522
581
  continue
523
582
  }
524
583
 
@@ -567,7 +626,8 @@ extension TrackPlayerCore {
567
626
 
568
627
  let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
569
628
  for id in assetsToRemove {
570
- self.preloadedAssets.removeValue(forKey: id)
629
+ // Cancel before dropping the last reference — see cancelLoading(of:).
630
+ self.preloadedAssets.removeValue(forKey: id)?.cancelLoading()
571
631
  }
572
632
 
573
633
  if !assetsToRemove.isEmpty {
@@ -64,7 +64,7 @@ extension TrackPlayerCore {
64
64
  notifyTracksNeedUpdate(tracks: [track], lookahead: lookaheadCount)
65
65
 
66
66
  // A preloaded asset for this track shares the same dead connection state.
67
- preloadedAssets.removeValue(forKey: trackId)
67
+ preloadedAssets.removeValue(forKey: trackId)?.cancelLoading()
68
68
  // Surface a buffering state while the retry is pending.
69
69
  isRecoveringFromStall = true
70
70
  emitStateChange()