react-native-nitro-player 1.5.0 → 1.6.0-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 (63) 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 +77 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  13. package/ios/HybridTrackPlayer.swift +58 -29
  14. package/ios/core/TrackPlayerCore.swift +22 -1
  15. package/ios/core/TrackPlayerListener.swift +84 -5
  16. package/ios/core/TrackPlayerNotify.swift +13 -7
  17. package/ios/core/TrackPlayerPlayback.swift +75 -39
  18. package/ios/core/TrackPlayerQueue.swift +28 -4
  19. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  20. package/ios/core/TrackPlayerRecovery.swift +1 -1
  21. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  22. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  23. package/ios/download/DownloadDatabase.swift +41 -13
  24. package/ios/media/MediaSessionManager.swift +19 -12
  25. package/ios/queue/HybridPlayerQueue.swift +10 -5
  26. package/lib/hooks/callbackManager.d.ts +27 -1
  27. package/lib/hooks/callbackManager.js +100 -1
  28. package/lib/hooks/index.d.ts +1 -0
  29. package/lib/hooks/index.js +1 -0
  30. package/lib/hooks/useActualQueue.js +18 -12
  31. package/lib/hooks/useNowPlaying.js +10 -2
  32. package/lib/hooks/useTimedMetadata.d.ts +16 -0
  33. package/lib/hooks/useTimedMetadata.js +21 -0
  34. package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
  35. package/lib/types/PlayerQueue.d.ts +7 -0
  36. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  37. package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
  38. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
  39. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  40. package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
  44. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  45. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
  46. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  47. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
  48. package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
  49. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  50. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  51. package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
  52. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  53. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  54. package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
  55. package/package.json +1 -1
  56. package/src/components/CastButton.tsx +14 -2
  57. package/src/hooks/callbackManager.ts +134 -2
  58. package/src/hooks/index.ts +1 -0
  59. package/src/hooks/useActualQueue.ts +18 -13
  60. package/src/hooks/useNowPlaying.ts +12 -2
  61. package/src/hooks/useTimedMetadata.ts +25 -0
  62. package/src/specs/TrackPlayer.nitro.ts +6 -0
  63. package/src/types/PlayerQueue.ts +8 -0
@@ -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()
@@ -9,159 +9,166 @@ import Foundation
9
9
  extension TrackPlayerCore {
10
10
 
11
11
  func loadPlaylist(playlistId: String, startIndex: Int? = nil) async {
12
- await withPlayerQueueNoThrow {
13
- self.playNextStack.removeAll()
14
- self.upNextQueue.removeAll()
15
- self.currentTemporaryType = .none
16
-
17
- NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "🎼", count: Constants.playlistSeparatorLength))
18
- NitroPlayerLogger.log("TrackPlayerCore", "📂 LOAD PLAYLIST REQUEST")
19
- NitroPlayerLogger.log("TrackPlayerCore", " Playlist ID: \(playlistId)")
20
- NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
21
-
22
- guard let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else {
23
- NitroPlayerLogger.log("TrackPlayerCore", " ❌ Playlist NOT FOUND")
24
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
25
- return
26
- }
12
+ await withPlayerQueueNoThrow { self.loadPlaylistOnQueue(playlistId: playlistId, startIndex: startIndex) }
13
+ }
27
14
 
28
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Found playlist: \(playlist.name)")
29
- NitroPlayerLogger.log("TrackPlayerCore", " 📋 Contains \(playlist.tracks.count) tracks:")
30
- for (index, track) in playlist.tracks.enumerated() {
31
- NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] \(track.title) - \(track.artist)")
32
- }
15
+ func loadPlaylistOnQueue(playlistId: String, startIndex: Int? = nil) {
16
+ self.playNextStack.removeAll()
17
+ self.upNextQueue.removeAll()
18
+ self.currentTemporaryType = .none
19
+
20
+ NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "🎼", count: Constants.playlistSeparatorLength))
21
+ NitroPlayerLogger.log("TrackPlayerCore", "📂 LOAD PLAYLIST REQUEST")
22
+ NitroPlayerLogger.log("TrackPlayerCore", " Playlist ID: \(playlistId)")
23
+ NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
24
+
25
+ guard let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else {
26
+ NitroPlayerLogger.log("TrackPlayerCore", " ❌ Playlist NOT FOUND")
33
27
  NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
28
+ return
29
+ }
34
30
 
35
- let targetIndex: Int
36
- if let startIndex {
37
- guard startIndex >= 0 && startIndex < playlist.tracks.count else {
38
- NitroPlayerLogger.log("TrackPlayerCore", " ❌ Invalid start index: \(startIndex) (track count: \(playlist.tracks.count))")
39
- return
40
- }
41
- targetIndex = startIndex
42
- } else {
43
- targetIndex = 0
44
- }
31
+ NitroPlayerLogger.log("TrackPlayerCore", " ✅ Found playlist: \(playlist.name)")
32
+ NitroPlayerLogger.log("TrackPlayerCore", " 📋 Contains \(playlist.tracks.count) tracks:")
33
+ for (index, track) in playlist.tracks.enumerated() {
34
+ NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] \(track.title) - \(track.artist)")
35
+ }
36
+ NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
45
37
 
46
- self.currentPlaylistId = playlistId
47
- if targetIndex == 0 {
48
- self.updatePlayerQueue(tracks: playlist.tracks)
49
- } else {
50
- // Bypass updatePlayerQueue to avoid emitting a spurious onTrackChange for index 0.
51
- // Set currentTracks directly so rebuildQueueFromPlaylistIndex can use them.
52
- self.currentTracks = playlist.tracks
53
- self.preloadedAssets.removeAll()
54
- _ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
38
+ let targetIndex: Int
39
+ if let startIndex {
40
+ guard startIndex >= 0 && startIndex < playlist.tracks.count else {
41
+ NitroPlayerLogger.log("TrackPlayerCore", " ❌ Invalid start index: \(startIndex) (track count: \(playlist.tracks.count))")
42
+ return
55
43
  }
56
- self.emitStateChange()
57
- self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
58
- self.notifyTemporaryQueueChange()
44
+ targetIndex = startIndex
45
+ } else {
46
+ targetIndex = 0
47
+ }
48
+
49
+ self.currentPlaylistId = playlistId
50
+ if targetIndex == 0 {
51
+ self.updatePlayerQueue(tracks: playlist.tracks)
52
+ } else {
53
+ // Bypass updatePlayerQueue to avoid emitting a spurious onTrackChange for index 0.
54
+ // Set currentTracks directly so rebuildQueueFromPlaylistIndex can use them.
55
+ self.currentTracks = playlist.tracks
56
+ for asset in self.preloadedAssets.values { asset.cancelLoading() }
57
+ self.preloadedAssets.removeAll()
58
+ _ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
59
59
  }
60
+ self.emitStateChange()
61
+ self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
62
+ self.notifyTemporaryQueueChange()
60
63
  }
61
64
 
65
+ /// Callable from any thread (PlaylistManager runs on its own queue) — all state
66
+ /// access happens on playerQueue.
62
67
  func updatePlaylist(playlistId: String) {
63
- guard currentPlaylistId == playlistId else { return }
64
-
65
- // Cancel any pending rebuild so back-to-back calls collapse into a single rebuild.
66
- pendingPlaylistUpdateWorkItem?.cancel()
67
-
68
- let workItem = DispatchWorkItem { [weak self] in
69
- guard let self, self.currentPlaylistId == playlistId,
70
- let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else { return }
68
+ playerQueue.async { [weak self] in
69
+ guard let self, self.currentPlaylistId == playlistId else { return }
70
+
71
+ self.playlistUpdateGeneration &+= 1
72
+ let generation = self.playlistUpdateGeneration
73
+
74
+ // Run at the tail of the current burst; stale generations drop out.
75
+ self.playerQueue.async { [weak self] in
76
+ guard let self, generation == self.playlistUpdateGeneration,
77
+ self.currentPlaylistId == playlistId,
78
+ let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else { return }
79
+
80
+ // If nothing is playing yet, do a full load
81
+ guard self.player?.currentItem != nil else {
82
+ self.updatePlayerQueue(tracks: playlist.tracks)
83
+ self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
84
+ return
85
+ }
71
86
 
72
- // If nothing is playing yet, do a full load
73
- guard self.player?.currentItem != nil else {
74
- self.updatePlayerQueue(tracks: playlist.tracks)
87
+ // Update tracks list without interrupting playback
88
+ self.currentTracks = playlist.tracks
89
+ self.rebuildAVQueueFromCurrentPosition()
75
90
  self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
76
- return
77
91
  }
78
-
79
- // Update tracks list without interrupting playback
80
- self.currentTracks = playlist.tracks
81
- self.rebuildAVQueueFromCurrentPosition()
82
- self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
83
92
  }
84
-
85
- pendingPlaylistUpdateWorkItem = workItem
86
- playerQueue.async(execute: workItem)
87
93
  }
88
94
 
89
- func playNext(trackId: String) async throws {
90
- try await withPlayerQueue {
91
- guard let track = self.findTrackById(trackId) else {
92
- throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
93
- }
94
- NitroPlayerLogger.log("TrackPlayerCore", "⏭️ playNext(\(trackId))")
95
- self.playNextStack.insert(track, at: 0)
96
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Added '\(track.title)' to playNext stack (position: 1)")
97
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
98
- self.notifyTemporaryQueueChange()
95
+ func playNextOnQueue(trackId: String) throws {
96
+ guard let track = findTrackById(trackId) else {
97
+ throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
99
98
  }
99
+ NitroPlayerLogger.log("TrackPlayerCore", "⏭️ playNext(\(trackId))")
100
+ playNextStack.insert(track, at: 0)
101
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
102
+ notifyTemporaryQueueChange()
103
+ }
104
+ func playNext(trackId: String) async throws {
105
+ try await withPlayerQueue { try self.playNextOnQueue(trackId: trackId) }
100
106
  }
101
107
 
102
- func addToUpNext(trackId: String) async throws {
103
- try await withPlayerQueue {
104
- guard let track = self.findTrackById(trackId) else {
105
- throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
106
- }
107
- NitroPlayerLogger.log("TrackPlayerCore", "📋 addToUpNext(\(trackId))")
108
- self.upNextQueue.append(track)
109
- NitroPlayerLogger.log("TrackPlayerCore", " ✅ Added '\(track.title)' to upNext queue (position: \(self.upNextQueue.count))")
110
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
111
- self.notifyTemporaryQueueChange()
108
+ func addToUpNextOnQueue(trackId: String) throws {
109
+ guard let track = findTrackById(trackId) else {
110
+ throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
112
111
  }
112
+ NitroPlayerLogger.log("TrackPlayerCore", "📋 addToUpNext(\(trackId))")
113
+ upNextQueue.append(track)
114
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
115
+ notifyTemporaryQueueChange()
116
+ }
117
+ func addToUpNext(trackId: String) async throws {
118
+ try await withPlayerQueue { try self.addToUpNextOnQueue(trackId: trackId) }
113
119
  }
114
120
 
121
+ func removeFromPlayNextOnQueue(trackId: String) -> Bool {
122
+ guard let idx = playNextStack.firstIndex(where: { $0.id == trackId }) else { return false }
123
+ playNextStack.remove(at: idx)
124
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
125
+ notifyTemporaryQueueChange()
126
+ return true
127
+ }
115
128
  func removeFromPlayNext(trackId: String) async -> Bool {
116
- await withPlayerQueueNoThrow {
117
- guard let idx = self.playNextStack.firstIndex(where: { $0.id == trackId }) else { return false }
118
- self.playNextStack.remove(at: idx)
119
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
120
- self.notifyTemporaryQueueChange()
121
- return true
122
- }
129
+ await withPlayerQueueNoThrow { self.removeFromPlayNextOnQueue(trackId: trackId) }
123
130
  }
124
131
 
132
+ func removeFromUpNextOnQueue(trackId: String) -> Bool {
133
+ guard let idx = upNextQueue.firstIndex(where: { $0.id == trackId }) else { return false }
134
+ upNextQueue.remove(at: idx)
135
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
136
+ notifyTemporaryQueueChange()
137
+ return true
138
+ }
125
139
  func removeFromUpNext(trackId: String) async -> Bool {
126
- await withPlayerQueueNoThrow {
127
- guard let idx = self.upNextQueue.firstIndex(where: { $0.id == trackId }) else { return false }
128
- self.upNextQueue.remove(at: idx)
129
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
130
- self.notifyTemporaryQueueChange()
131
- return true
132
- }
140
+ await withPlayerQueueNoThrow { self.removeFromUpNextOnQueue(trackId: trackId) }
133
141
  }
134
142
 
135
- func clearPlayNext() async {
136
- await withPlayerQueueNoThrow {
137
- self.playNextStack.removeAll()
138
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
139
- self.notifyTemporaryQueueChange()
140
- }
143
+ func clearPlayNextOnQueue() {
144
+ playNextStack.removeAll()
145
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
146
+ notifyTemporaryQueueChange()
141
147
  }
148
+ func clearPlayNext() async { await withPlayerQueueNoThrow { self.clearPlayNextOnQueue() } }
142
149
 
143
- func clearUpNext() async {
144
- await withPlayerQueueNoThrow {
145
- self.upNextQueue.removeAll()
146
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
147
- self.notifyTemporaryQueueChange()
148
- }
150
+ func clearUpNextOnQueue() {
151
+ upNextQueue.removeAll()
152
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
153
+ notifyTemporaryQueueChange()
154
+ }
155
+ func clearUpNext() async { await withPlayerQueueNoThrow { self.clearUpNextOnQueue() } }
156
+
157
+ func reorderTemporaryTrackOnQueue(trackId: String, newIndex: Int) -> Bool {
158
+ var combined = playNextStack + upNextQueue
159
+ guard let fromIdx = combined.firstIndex(where: { $0.id == trackId }) else { return false }
160
+ let track = combined.remove(at: fromIdx)
161
+ let clamped = newIndex.clamped(to: 0...combined.count)
162
+ combined.insert(track, at: clamped)
163
+ let pnSize = playNextStack.count
164
+ playNextStack = Array(combined.prefix(pnSize))
165
+ upNextQueue = Array(combined.dropFirst(pnSize))
166
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
167
+ notifyTemporaryQueueChange()
168
+ return true
149
169
  }
150
-
151
170
  func reorderTemporaryTrack(trackId: String, newIndex: Int) async -> Bool {
152
- await withPlayerQueueNoThrow {
153
- var combined = self.playNextStack + self.upNextQueue
154
- guard let fromIdx = combined.firstIndex(where: { $0.id == trackId }) else { return false }
155
- let track = combined.remove(at: fromIdx)
156
- let clamped = newIndex.clamped(to: 0...combined.count)
157
- combined.insert(track, at: clamped)
158
- let pnSize = self.playNextStack.count
159
- self.playNextStack = Array(combined.prefix(pnSize))
160
- self.upNextQueue = Array(combined.dropFirst(pnSize))
161
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
162
- self.notifyTemporaryQueueChange()
163
- return true
164
- }
171
+ await withPlayerQueueNoThrow { self.reorderTemporaryTrackOnQueue(trackId: trackId, newIndex: newIndex) }
165
172
  }
166
173
 
167
174
  func getPlayNextQueue() async -> [TrackItem] {
@@ -71,7 +71,7 @@ extension TrackPlayerCore {
71
71
  for trackId in updatedTrackIds {
72
72
  if self.preloadedAssets[trackId] != nil {
73
73
  NitroPlayerLogger.log("TrackPlayerCore", "🗑️ Invalidating preloaded asset for track: \(trackId)")
74
- self.preloadedAssets.removeValue(forKey: trackId)
74
+ self.preloadedAssets.removeValue(forKey: trackId)?.cancelLoading()
75
75
  }
76
76
  }
77
77
 
@@ -129,7 +129,7 @@ extension TrackPlayerCore {
129
129
  // No AVPlayerItem exists yet — lazy-load mode: URLs were empty when the queue first loaded.
130
130
  NitroPlayerLogger.log("TrackPlayerCore",
131
131
  "🔄 No current item — full queue rebuild from currentTrackIndex \(self.currentTrackIndex)")
132
- player.removeAllItems()
132
+ self.removeAllItemsCancellingLoads(player)
133
133
  var lastItem: AVPlayerItem? = nil
134
134
  for (offset, track) in self.currentTracks[max(0, self.currentTrackIndex)...].enumerated() {
135
135
  let isPreload = offset < Constants.gaplessPreloadCount
@@ -164,17 +164,41 @@ extension TrackPlayerCore {
164
164
  }
165
165
  }
166
166
 
167
+ /// Walks the queue structure directly instead of materializing the whole queue —
168
+ /// this runs on every skip and on the periodic boundary observer, so it must not
169
+ /// be O(playlist) just to read the next few tracks.
167
170
  func getNextTracksInternal(count: Int) -> [TrackItem] {
168
- let actualQueue = getActualQueueInternal()
169
- guard !actualQueue.isEmpty else { return [] }
171
+ guard count > 0 else { return [] }
172
+ var out: [TrackItem] = []
173
+ out.reserveCapacity(count)
174
+ let currentId = activeCurrentTrackId
175
+
176
+ var skippedPlayNext = currentTemporaryType != .playNext
177
+ for track in playNextStack {
178
+ if !skippedPlayNext && track.id == currentId {
179
+ skippedPlayNext = true
180
+ continue
181
+ }
182
+ out.append(track)
183
+ if out.count == count { return out }
184
+ }
170
185
 
171
- guard let currentTrack = getCurrentTrack(),
172
- let currentIndex = actualQueue.firstIndex(where: { $0.id == currentTrack.id })
173
- else { return [] }
186
+ var skippedUpNext = currentTemporaryType != .upNext
187
+ for track in upNextQueue {
188
+ if !skippedUpNext && track.id == currentId {
189
+ skippedUpNext = true
190
+ continue
191
+ }
192
+ out.append(track)
193
+ if out.count == count { return out }
194
+ }
174
195
 
175
- let startIndex = currentIndex + 1
176
- let endIndex = min(startIndex + count, actualQueue.count)
177
- return startIndex < actualQueue.count ? Array(actualQueue[startIndex..<endIndex]) : []
196
+ var index = currentTrackIndex + 1
197
+ while index < currentTracks.count && out.count < count {
198
+ out.append(currentTracks[index])
199
+ index += 1
200
+ }
201
+ return out
178
202
  }
179
203
 
180
204
  func checkUpcomingTracksForUrls(lookahead: Int = 5) {