react-native-nitro-player 0.7.0 โ†’ 0.7.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 (107) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +9 -13
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +45 -90
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +48 -182
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +21 -77
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +55 -104
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +113 -123
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +82 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ListenerRegistry.kt +48 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +62 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +153 -1887
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +122 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +44 -0
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +162 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +165 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +161 -0
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +28 -0
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +121 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +98 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +27 -18
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +11 -58
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +13 -30
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +102 -162
  23. package/ios/HybridDownloadManager.swift +32 -26
  24. package/ios/HybridEqualizer.swift +48 -35
  25. package/ios/HybridTrackPlayer.swift +127 -102
  26. package/ios/core/ListenerRegistry.swift +60 -0
  27. package/ios/core/TrackPlayerCore.swift +130 -2356
  28. package/ios/core/TrackPlayerListener.swift +395 -0
  29. package/ios/core/TrackPlayerNotify.swift +52 -0
  30. package/ios/core/TrackPlayerPlayback.swift +274 -0
  31. package/ios/core/TrackPlayerQueue.swift +212 -0
  32. package/ios/core/TrackPlayerQueueBuild.swift +482 -0
  33. package/ios/core/TrackPlayerTempQueue.swift +167 -0
  34. package/ios/core/TrackPlayerUrlLoader.swift +169 -0
  35. package/ios/equalizer/EqualizerCore.swift +24 -89
  36. package/ios/media/MediaSessionManager.swift +32 -49
  37. package/ios/playlist/PlaylistManager.swift +2 -9
  38. package/ios/queue/HybridPlayerQueue.swift +69 -66
  39. package/lib/hooks/useDownloadedTracks.js +16 -13
  40. package/lib/hooks/useEqualizer.d.ts +4 -4
  41. package/lib/hooks/useEqualizer.js +12 -12
  42. package/lib/hooks/useEqualizerPresets.d.ts +3 -3
  43. package/lib/hooks/useEqualizerPresets.js +12 -18
  44. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +2 -2
  45. package/lib/specs/AudioDevices.nitro.d.ts +2 -2
  46. package/lib/specs/DownloadManager.nitro.d.ts +10 -10
  47. package/lib/specs/Equalizer.nitro.d.ts +9 -9
  48. package/lib/specs/TrackPlayer.nitro.d.ts +38 -16
  49. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  50. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp +122 -0
  51. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +31 -6
  52. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +2 -2
  53. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.cpp +16 -3
  54. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.hpp +1 -1
  55. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.cpp +154 -44
  56. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.hpp +10 -10
  57. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +130 -34
  58. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +9 -9
  59. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +115 -24
  60. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +8 -8
  61. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +243 -24
  62. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +16 -8
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_TrackItem__std__vector_TrackItem_.kt +80 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +3 -2
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAudioDevicesSpec.kt +2 -1
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridDownloadManagerSpec.kt +10 -10
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridEqualizerSpec.kt +10 -9
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +9 -8
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +45 -8
  70. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +74 -18
  71. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +380 -151
  72. package/nitrogen/generated/ios/c++/HybridDownloadManagerSpecSwift.hpp +10 -10
  73. package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +12 -9
  74. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +23 -8
  75. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +82 -8
  76. package/nitrogen/generated/ios/swift/Func_void_EqualizerState.swift +46 -0
  77. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedPlaylist_.swift +58 -0
  78. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedTrack_.swift +58 -0
  79. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__std__string_.swift +58 -0
  80. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedPlaylist_.swift +46 -0
  81. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedTrack_.swift +46 -0
  82. package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +5 -5
  83. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__std__vector_TrackItem_.swift +46 -0
  84. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec.swift +10 -10
  85. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec_cxx.swift +141 -71
  86. package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +9 -9
  87. package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +105 -41
  88. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +8 -8
  89. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +95 -32
  90. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +16 -8
  91. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +267 -32
  92. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +3 -2
  93. package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.hpp +2 -1
  94. package/nitrogen/generated/shared/c++/HybridDownloadManagerSpec.hpp +10 -10
  95. package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +10 -9
  96. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +9 -8
  97. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +8 -0
  98. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +16 -8
  99. package/package.json +1 -1
  100. package/src/hooks/useDownloadedTracks.ts +17 -13
  101. package/src/hooks/useEqualizer.ts +16 -16
  102. package/src/hooks/useEqualizerPresets.ts +15 -21
  103. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +2 -2
  104. package/src/specs/AudioDevices.nitro.ts +2 -2
  105. package/src/specs/DownloadManager.nitro.ts +10 -10
  106. package/src/specs/Equalizer.nitro.ts +9 -9
  107. package/src/specs/TrackPlayer.nitro.ts +52 -16
@@ -0,0 +1,482 @@
1
+ //
2
+ // TrackPlayerQueueBuild.swift
3
+ // NitroPlayer
4
+ //
5
+ // Created by Ritesh Shukla on 25/03/26.
6
+ //
7
+
8
+ import AVFoundation
9
+
10
+ import Foundation
11
+
12
+ extension TrackPlayerCore {
13
+
14
+ func updatePlayerQueue(tracks: [TrackItem]) {
15
+ NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "=", count: Constants.separatorLineLength))
16
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ“‹ UPDATE PLAYER QUEUE - Received \(tracks.count) tracks")
17
+ NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "=", count: Constants.separatorLineLength))
18
+
19
+ #if DEBUG
20
+ for (index, track) in tracks.enumerated() {
21
+ let isDownloaded = DownloadManagerCore.shared.isTrackDownloaded(trackId: track.id)
22
+ let downloadStatus = isDownloaded ? "๐Ÿ“ฅ DOWNLOADED" : "๐ŸŒ REMOTE"
23
+ NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] ๐ŸŽต \(track.title) - \(track.artist) (ID: \(track.id)) - \(downloadStatus)")
24
+ if isDownloaded {
25
+ if let localPath = DownloadManagerCore.shared.getLocalPath(trackId: track.id) {
26
+ NitroPlayerLogger.log("TrackPlayerCore", " Local path: \(localPath)")
27
+ }
28
+ }
29
+ }
30
+ NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "=", count: Constants.separatorLineLength) + "\n")
31
+ #endif
32
+
33
+ // Store tracks for index tracking
34
+ currentTracks = tracks
35
+ currentTrackIndex = 0
36
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ”ข Reset currentTrackIndex to 0 (will be updated by KVO observer)")
37
+
38
+ // Remove old boundary observer if exists
39
+ if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
40
+ currentPlayer.removeTimeObserver(boundaryObserver)
41
+ boundaryTimeObserver = nil
42
+ }
43
+
44
+ // Re-enable stall waiting for the new first track
45
+ player?.automaticallyWaitsToMinimizeStalling = true
46
+
47
+ // Clear old preloaded assets when loading new queue
48
+ preloadedAssets.removeAll()
49
+
50
+ guard let existingPlayer = self.player else {
51
+ NitroPlayerLogger.log("TrackPlayerCore", "โŒ No player available")
52
+ return
53
+ }
54
+
55
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ”„ Removing \(existingPlayer.items().count) old items from player")
56
+ existingPlayer.removeAllItems()
57
+
58
+ // Lazy-load mode: if any track has no URL AND is not downloaded locally,
59
+ // we can't create an AVPlayerItem for it and the queue order would be wrong.
60
+ let isLazyLoad = tracks.contains {
61
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
62
+ }
63
+ if isLazyLoad {
64
+ NitroPlayerLogger.log("TrackPlayerCore", "โณ Lazy-load mode โ€” player cleared, awaiting URL resolution")
65
+ return
66
+ }
67
+
68
+ let items = tracks.enumerated().compactMap { (index, track) -> AVPlayerItem? in
69
+ let isPreload = index < Constants.gaplessPreloadCount
70
+ return createGaplessPlayerItem(for: track, isPreload: isPreload)
71
+ }
72
+
73
+ NitroPlayerLogger.log("TrackPlayerCore", "๐ŸŽต Created \(items.count) gapless-optimized player items")
74
+
75
+ guard !items.isEmpty else {
76
+ NitroPlayerLogger.log("TrackPlayerCore", "โŒ No valid items to play")
77
+ return
78
+ }
79
+
80
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ”„ Adding \(items.count) new items to player")
81
+
82
+ var lastItem: AVPlayerItem? = nil
83
+ for (index, item) in items.enumerated() {
84
+ existingPlayer.insert(item, after: lastItem)
85
+ lastItem = item
86
+
87
+ #if DEBUG
88
+ if let trackId = item.trackId, let track = tracks.first(where: { $0.id == trackId }) {
89
+ NitroPlayerLogger.log("TrackPlayerCore", " โž• Added to player queue [\(index + 1)]: \(track.title)")
90
+ }
91
+ #endif
92
+ }
93
+
94
+ #if DEBUG
95
+ let trackById = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
96
+ NitroPlayerLogger.log("TrackPlayerCore", "\n๐Ÿ” VERIFICATION - Player now has \(existingPlayer.items().count) items:")
97
+ for (index, item) in existingPlayer.items().enumerated() {
98
+ if let trackId = item.trackId, let track = trackById[trackId] {
99
+ NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] โœ“ \(track.title) - \(track.artist) (ID: \(track.id))")
100
+ } else {
101
+ NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] โš ๏ธ Unknown item (no trackId)")
102
+ }
103
+ }
104
+ if let currentItem = existingPlayer.currentItem,
105
+ let trackId = currentItem.trackId,
106
+ let track = trackById[trackId]
107
+ {
108
+ NitroPlayerLogger.log("TrackPlayerCore", "โ–ถ๏ธ Current item: \(track.title)")
109
+ }
110
+ NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "=", count: Constants.separatorLineLength) + "\n")
111
+ #endif
112
+
113
+ // Notify track change
114
+ if let firstTrack = tracks.first {
115
+ NitroPlayerLogger.log("TrackPlayerCore", "๐ŸŽต Emitting track change: \(firstTrack.title)")
116
+ notifyTrackChange(firstTrack, nil)
117
+ }
118
+
119
+ // Start preloading upcoming tracks for gapless playback
120
+ preloadUpcomingTracks(from: 1)
121
+
122
+ NitroPlayerLogger.log("TrackPlayerCore", "โœ… Queue updated with \(items.count) gapless-optimized tracks")
123
+ }
124
+
125
+ /// Clears temporary tracks, rebuilds AVQueuePlayer from `index` in the original playlist,
126
+ /// and resumes playback only if the player was already playing (preserves paused state).
127
+ @discardableResult
128
+ func rebuildQueueFromPlaylistIndex(index: Int) -> Bool {
129
+ guard index >= 0 && index < self.currentTracks.count else {
130
+ NitroPlayerLogger.log("TrackPlayerCore", "โŒ rebuildQueueFromPlaylistIndex - invalid index \(index), currentTracks.count = \(self.currentTracks.count)")
131
+ return false
132
+ }
133
+
134
+ NitroPlayerLogger.log("TrackPlayerCore", "\n๐ŸŽฏ REBUILD QUEUE FROM PLAYLIST INDEX \(index)")
135
+ NitroPlayerLogger.log("TrackPlayerCore", " Total tracks in playlist: \(self.currentTracks.count)")
136
+ NitroPlayerLogger.log("TrackPlayerCore", " Current index: \(self.currentTrackIndex), target index: \(index)")
137
+
138
+ // Preserve playback state โ€” only resume if already playing.
139
+ let wasPlaying = self.player?.rate ?? 0 > 0
140
+
141
+ // Clear temporary tracks when jumping to specific index
142
+ self.playNextStack.removeAll()
143
+ self.upNextQueue.removeAll()
144
+ self.currentTemporaryType = .none
145
+ NitroPlayerLogger.log("TrackPlayerCore", " ๐Ÿงน Cleared temporary tracks")
146
+
147
+ let fullPlaylist = self.currentTracks
148
+
149
+ // Update currentTrackIndex BEFORE updating queue
150
+ self.currentTrackIndex = index
151
+
152
+ // Lazy-load guard: if the target track has no URL AND is not downloaded locally,
153
+ // the queue can't be built. Defer to updateTracks once URL resolution completes.
154
+ let targetTrack = fullPlaylist[index]
155
+ let isLazyLoad = targetTrack.url.isEmpty
156
+ && !DownloadManagerCore.shared.isTrackDownloaded(trackId: targetTrack.id)
157
+ if isLazyLoad {
158
+ NitroPlayerLogger.log("TrackPlayerCore", " โณ Lazy-load โ€” deferring AVQueuePlayer setup; emitting track change for index \(index)")
159
+ self.currentTracks = fullPlaylist
160
+ if let track = self.currentTracks[safe: index] {
161
+ notifyTrackChange(track, .skip)
162
+ }
163
+ return true
164
+ }
165
+
166
+ let tracksToPlay = Array(fullPlaylist[index...])
167
+ NitroPlayerLogger.log("TrackPlayerCore", " ๐Ÿ”„ Creating gapless queue with \(tracksToPlay.count) tracks starting from index \(index)")
168
+
169
+ let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
170
+ let isPreload = offset < Constants.gaplessPreloadCount
171
+ return self.createGaplessPlayerItem(for: track, isPreload: isPreload)
172
+ }
173
+
174
+ guard let player = self.player, !items.isEmpty else {
175
+ NitroPlayerLogger.log("TrackPlayerCore", "โŒ No player or no items to play")
176
+ return false
177
+ }
178
+
179
+ // Remove old boundary observer
180
+ if let boundaryObserver = self.boundaryTimeObserver {
181
+ player.removeTimeObserver(boundaryObserver)
182
+ self.boundaryTimeObserver = nil
183
+ }
184
+
185
+ // Re-enable stall waiting for the new first track
186
+ player.automaticallyWaitsToMinimizeStalling = true
187
+
188
+ player.removeAllItems()
189
+ var lastItem: AVPlayerItem? = nil
190
+ for item in items {
191
+ player.insert(item, after: lastItem)
192
+ lastItem = item
193
+ }
194
+
195
+ // Restore the full playlist reference (don't slice it!)
196
+ self.currentTracks = fullPlaylist
197
+
198
+ NitroPlayerLogger.log("TrackPlayerCore", " โœ… Gapless queue recreated. Now at index: \(self.currentTrackIndex)")
199
+ if let track = self.getCurrentTrack() {
200
+ NitroPlayerLogger.log("TrackPlayerCore", " ๐ŸŽต Playing: \(track.title)")
201
+ notifyTrackChange(track, .skip)
202
+ }
203
+
204
+ self.preloadUpcomingTracks(from: index + 1)
205
+
206
+ if wasPlaying { player.play() }
207
+ return true
208
+ }
209
+
210
+ /// Rebuilds the AVQueuePlayer from the current position with temporary tracks.
211
+ /// Order: [current] + [playNext stack] + [upNext queue] + [remaining original]
212
+ ///
213
+ /// - Parameter changedTrackIds: When non-nil, performs a surgical update:
214
+ /// only AVPlayerItems whose track ID is in this set are removed and re-created.
215
+ func rebuildAVQueueFromCurrentPosition(changedTrackIds: Set<String>? = nil) {
216
+ guard let player = self.player else { return }
217
+
218
+ let currentItem = player.currentItem
219
+ let playingItems = player.items()
220
+
221
+ // If the currently playing AVPlayerItem is no longer in currentTracks,
222
+ // delegate to rebuildQueueFromPlaylistIndex so the player immediately
223
+ // starts what is now at currentTrackIndex in the updated list.
224
+ if let playingTrackId = currentItem?.trackId,
225
+ !currentTracks.contains(where: { $0.id == playingTrackId }) {
226
+ let targetIndex = currentTrackIndex < currentTracks.count
227
+ ? currentTrackIndex : currentTracks.count - 1
228
+ if targetIndex >= 0 {
229
+ _ = rebuildQueueFromPlaylistIndex(index: targetIndex)
230
+ }
231
+ return
232
+ }
233
+
234
+ // Build the desired upcoming track list
235
+ var newQueueTracks: [TrackItem] = []
236
+
237
+ if currentTemporaryType == .playNext && playNextStack.count > 1 {
238
+ newQueueTracks.append(contentsOf: playNextStack.dropFirst())
239
+ } else if currentTemporaryType != .playNext {
240
+ newQueueTracks.append(contentsOf: playNextStack)
241
+ }
242
+
243
+ if currentTemporaryType == .upNext && upNextQueue.count > 1 {
244
+ newQueueTracks.append(contentsOf: upNextQueue.dropFirst())
245
+ } else if currentTemporaryType != .upNext {
246
+ newQueueTracks.append(contentsOf: upNextQueue)
247
+ }
248
+
249
+ if currentTrackIndex + 1 < currentTracks.count {
250
+ newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
251
+ }
252
+
253
+ // Collect existing upcoming AVPlayerItems
254
+ let upcomingItems: [AVPlayerItem]
255
+ if let ci = currentItem, let ciIndex = playingItems.firstIndex(of: ci) {
256
+ upcomingItems = Array(playingItems.suffix(from: playingItems.index(after: ciIndex)))
257
+ } else {
258
+ upcomingItems = []
259
+ }
260
+
261
+ let existingIds = upcomingItems.compactMap { $0.trackId }
262
+ let desiredIds = newQueueTracks.map { $0.id }
263
+
264
+ // Fast-path: nothing to do if queue already matches
265
+ if existingIds == desiredIds {
266
+ if let changedIds = changedTrackIds {
267
+ if Set(existingIds).isDisjoint(with: changedIds) {
268
+ NitroPlayerLogger.log("TrackPlayerCore",
269
+ "โœ… Queue matches & no buffered URLs changed โ€” preserving \(existingIds.count) items for gapless")
270
+ return
271
+ }
272
+ } else {
273
+ NitroPlayerLogger.log("TrackPlayerCore",
274
+ "โœ… Queue already matches desired order โ€” preserving \(existingIds.count) items for gapless")
275
+ return
276
+ }
277
+ }
278
+
279
+ // Surgical path (changedTrackIds provided, e.g. from updateTracks)
280
+ if let changedIds = changedTrackIds {
281
+ var reusableByTrackId: [String: AVPlayerItem] = [:]
282
+ for item in upcomingItems {
283
+ if let trackId = item.trackId, !changedIds.contains(trackId) {
284
+ reusableByTrackId[trackId] = item
285
+ }
286
+ }
287
+
288
+ let desiredIdSet = Set(desiredIds)
289
+ for item in upcomingItems {
290
+ guard let trackId = item.trackId else { continue }
291
+ if changedIds.contains(trackId) || !desiredIdSet.contains(trackId) {
292
+ player.remove(item)
293
+ }
294
+ }
295
+
296
+ var lastAnchor: AVPlayerItem? = currentItem
297
+ for (offset, trackId) in desiredIds.enumerated() {
298
+ if let reusable = reusableByTrackId[trackId] {
299
+ lastAnchor = reusable
300
+ } else if let track = newQueueTracks.first(where: { $0.id == trackId }),
301
+ let newItem = createGaplessPlayerItem(for: track, isPreload: offset < Constants.gaplessPreloadCount)
302
+ {
303
+ player.insert(newItem, after: lastAnchor)
304
+ lastAnchor = newItem
305
+ }
306
+ }
307
+
308
+ let preserved = reusableByTrackId.count
309
+ let inserted = desiredIds.count - preserved
310
+ NitroPlayerLogger.log("TrackPlayerCore",
311
+ "๐Ÿ”„ Surgical rebuild: preserved \(preserved) buffered items, inserted \(inserted) new items")
312
+ return
313
+ }
314
+
315
+ // Full rebuild path (no changedTrackIds โ€” skip, reorder, etc.)
316
+ for item in playingItems where item != currentItem {
317
+ player.remove(item)
318
+ }
319
+
320
+ var lastItem = currentItem
321
+ for (offset, track) in newQueueTracks.enumerated() {
322
+ let isPreload = offset < Constants.gaplessPreloadCount
323
+ if let item = createGaplessPlayerItem(for: track, isPreload: isPreload) {
324
+ player.insert(item, after: lastItem)
325
+ lastItem = item
326
+ }
327
+ }
328
+ }
329
+
330
+ /// Creates a gapless-optimized AVPlayerItem with proper buffering configuration
331
+ func createGaplessPlayerItem(for track: TrackItem, isPreload: Bool = false) -> AVPlayerItem? {
332
+ let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
333
+
334
+ let url: URL
335
+ let isLocal = effectiveUrlString.hasPrefix("/")
336
+
337
+ if isLocal {
338
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ“ฅ Using DOWNLOADED version for \(track.title)")
339
+ NitroPlayerLogger.log("TrackPlayerCore", " Local path: \(effectiveUrlString)")
340
+
341
+ if FileManager.default.fileExists(atPath: effectiveUrlString) {
342
+ url = URL(fileURLWithPath: effectiveUrlString)
343
+ NitroPlayerLogger.log("TrackPlayerCore", " File URL: \(url.absoluteString)")
344
+ NitroPlayerLogger.log("TrackPlayerCore", " โœ… File verified to exist")
345
+ } else {
346
+ NitroPlayerLogger.log("TrackPlayerCore", " โŒ Downloaded file does NOT exist at path!")
347
+ NitroPlayerLogger.log("TrackPlayerCore", " Falling back to remote URL: \(track.url)")
348
+ guard let remoteUrl = URL(string: track.url) else {
349
+ NitroPlayerLogger.log("TrackPlayerCore", "โŒ Invalid remote URL: \(track.url)")
350
+ return nil
351
+ }
352
+ url = remoteUrl
353
+ }
354
+ } else {
355
+ guard let remoteUrl = URL(string: effectiveUrlString) else {
356
+ NitroPlayerLogger.log("TrackPlayerCore", "โŒ Invalid URL for track: \(track.title) - \(effectiveUrlString)")
357
+ return nil
358
+ }
359
+ url = remoteUrl
360
+ NitroPlayerLogger.log("TrackPlayerCore", "๐ŸŒ Using REMOTE version for \(track.title)")
361
+ }
362
+
363
+ let asset: AVURLAsset
364
+ if let preloadedAsset = preloadedAssets[track.id] {
365
+ asset = preloadedAsset
366
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿš€ Using preloaded asset for \(track.title)")
367
+ } else {
368
+ asset = AVURLAsset(url: url, options: [
369
+ AVURLAssetPreferPreciseDurationAndTimingKey: true
370
+ ])
371
+ }
372
+
373
+ let item = AVPlayerItem(asset: asset)
374
+
375
+ // Let the system choose the optimal forward buffer size (0 = automatic).
376
+ item.preferredForwardBufferDuration = 0
377
+
378
+ item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
379
+ item.trackId = track.id
380
+
381
+ if isPreload {
382
+ asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
383
+ var allKeysLoaded = true
384
+ for key in Constants.preloadAssetKeys {
385
+ var error: NSError?
386
+ let status = asset.statusOfValue(forKey: key, error: &error)
387
+ if status == .failed {
388
+ NitroPlayerLogger.log("TrackPlayerCore", "โš ๏ธ Failed to load key '\(key)' for \(track.title): \(error?.localizedDescription ?? "unknown")")
389
+ allKeysLoaded = false
390
+ }
391
+ }
392
+ if allKeysLoaded {
393
+ NitroPlayerLogger.log("TrackPlayerCore", "โœ… All asset keys preloaded for \(track.title)")
394
+ }
395
+ // "tracks" key is now loaded โ€” EQ tap attaches synchronously
396
+ EqualizerCore.shared.applyAudioMix(to: item)
397
+ }
398
+ } else {
399
+ EqualizerCore.shared.applyAudioMix(to: item)
400
+ }
401
+
402
+ return item
403
+ }
404
+
405
+ /// Preloads assets for upcoming tracks to enable gapless playback
406
+ func preloadUpcomingTracks(from startIndex: Int) {
407
+ // Capture the set of track IDs that already have AVPlayerItems in the queue.
408
+ let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
409
+
410
+ preloadQueue.async { [weak self] in
411
+ guard let self else { return }
412
+
413
+ let tracks = self.currentTracks
414
+ let endIndex = min(startIndex + Constants.gaplessPreloadCount, tracks.count)
415
+
416
+ for i in startIndex..<endIndex {
417
+ guard i < tracks.count else { break }
418
+ let track = tracks[i]
419
+
420
+ if self.preloadedAssets[track.id] != nil || queuedTrackIds.contains(track.id) {
421
+ continue
422
+ }
423
+
424
+ let effectiveUrlString = DownloadManagerCore.shared.getEffectiveUrl(track: track)
425
+ let isLocal = effectiveUrlString.hasPrefix("/")
426
+
427
+ let url: URL
428
+ if isLocal {
429
+ url = URL(fileURLWithPath: effectiveUrlString)
430
+ } else {
431
+ guard let remoteUrl = URL(string: effectiveUrlString) else { continue }
432
+ url = remoteUrl
433
+ }
434
+
435
+ let asset = AVURLAsset(url: url, options: [
436
+ AVURLAssetPreferPreciseDurationAndTimingKey: true
437
+ ])
438
+
439
+ asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
440
+ var allKeysLoaded = true
441
+ for key in Constants.preloadAssetKeys {
442
+ var error: NSError?
443
+ let status = asset.statusOfValue(forKey: key, error: &error)
444
+ if status != .loaded {
445
+ allKeysLoaded = false
446
+ break
447
+ }
448
+ }
449
+
450
+ if allKeysLoaded {
451
+ self?.playerQueue.async {
452
+ self?.preloadedAssets[track.id] = asset
453
+ NitroPlayerLogger.log("TrackPlayerCore", "๐ŸŽฏ Preloaded asset for upcoming track: \(track.title)")
454
+ }
455
+ }
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ /// Clears preloaded assets that are no longer needed
462
+ func cleanupPreloadedAssets(keepingFrom currentIndex: Int) {
463
+ // Already on playerQueue โ€” access preloadedAssets directly
464
+ let keepRange =
465
+ currentIndex..<min(
466
+ currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
467
+ let keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
468
+
469
+ let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
470
+ for id in assetsToRemove {
471
+ self.preloadedAssets.removeValue(forKey: id)
472
+ }
473
+
474
+ if !assetsToRemove.isEmpty {
475
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿงน Cleaned up \(assetsToRemove.count) preloaded assets")
476
+ }
477
+ }
478
+
479
+ func getAllPlaylists() -> [Playlist] {
480
+ playlistManager.getAllPlaylists().map { $0.toGeneratedPlaylist() }
481
+ }
482
+ }
@@ -0,0 +1,167 @@
1
+ //
2
+ // TrackPlayerTempQueue.swift
3
+ // NitroPlayer
4
+ //
5
+ // Created by Ritesh Shukla on 25/03/26.
6
+ //
7
+ import Foundation
8
+
9
+ extension TrackPlayerCore {
10
+
11
+ func loadPlaylist(playlistId: String) 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
+ }
27
+
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
+ }
33
+ NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength) + "\n")
34
+
35
+ self.currentPlaylistId = playlistId
36
+ self.updatePlayerQueue(tracks: playlist.tracks)
37
+ self.emitStateChange()
38
+ self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
39
+ self.notifyTemporaryQueueChange()
40
+ }
41
+ }
42
+
43
+ func updatePlaylist(playlistId: String) {
44
+ guard currentPlaylistId == playlistId else { return }
45
+
46
+ // Cancel any pending rebuild so back-to-back calls collapse into a single rebuild.
47
+ pendingPlaylistUpdateWorkItem?.cancel()
48
+
49
+ let workItem = DispatchWorkItem { [weak self] in
50
+ guard let self, self.currentPlaylistId == playlistId,
51
+ let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else { return }
52
+
53
+ // If nothing is playing yet, do a full load
54
+ guard self.player?.currentItem != nil else {
55
+ self.updatePlayerQueue(tracks: playlist.tracks)
56
+ return
57
+ }
58
+
59
+ // Update tracks list without interrupting playback
60
+ self.currentTracks = playlist.tracks
61
+ self.rebuildAVQueueFromCurrentPosition()
62
+ }
63
+
64
+ pendingPlaylistUpdateWorkItem = workItem
65
+ playerQueue.async(execute: workItem)
66
+ }
67
+
68
+ func playNext(trackId: String) async throws {
69
+ try await withPlayerQueue {
70
+ guard let track = self.findTrackById(trackId) else {
71
+ throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
72
+ }
73
+ NitroPlayerLogger.log("TrackPlayerCore", "โญ๏ธ playNext(\(trackId))")
74
+ self.playNextStack.insert(track, at: 0)
75
+ NitroPlayerLogger.log("TrackPlayerCore", " โœ… Added '\(track.title)' to playNext stack (position: 1)")
76
+ if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
77
+ self.notifyTemporaryQueueChange()
78
+ }
79
+ }
80
+
81
+ func addToUpNext(trackId: String) async throws {
82
+ try await withPlayerQueue {
83
+ guard let track = self.findTrackById(trackId) else {
84
+ throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
85
+ }
86
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ“‹ addToUpNext(\(trackId))")
87
+ self.upNextQueue.append(track)
88
+ NitroPlayerLogger.log("TrackPlayerCore", " โœ… Added '\(track.title)' to upNext queue (position: \(self.upNextQueue.count))")
89
+ if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
90
+ self.notifyTemporaryQueueChange()
91
+ }
92
+ }
93
+
94
+ func removeFromPlayNext(trackId: String) async -> Bool {
95
+ await withPlayerQueueNoThrow {
96
+ guard let idx = self.playNextStack.firstIndex(where: { $0.id == trackId }) else { return false }
97
+ self.playNextStack.remove(at: idx)
98
+ if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
99
+ self.notifyTemporaryQueueChange()
100
+ return true
101
+ }
102
+ }
103
+
104
+ func removeFromUpNext(trackId: String) async -> Bool {
105
+ await withPlayerQueueNoThrow {
106
+ guard let idx = self.upNextQueue.firstIndex(where: { $0.id == trackId }) else { return false }
107
+ self.upNextQueue.remove(at: idx)
108
+ if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
109
+ self.notifyTemporaryQueueChange()
110
+ return true
111
+ }
112
+ }
113
+
114
+ func clearPlayNext() async {
115
+ await withPlayerQueueNoThrow {
116
+ self.playNextStack.removeAll()
117
+ if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
118
+ self.notifyTemporaryQueueChange()
119
+ }
120
+ }
121
+
122
+ func clearUpNext() async {
123
+ await withPlayerQueueNoThrow {
124
+ self.upNextQueue.removeAll()
125
+ if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
126
+ self.notifyTemporaryQueueChange()
127
+ }
128
+ }
129
+
130
+ func reorderTemporaryTrack(trackId: String, newIndex: Int) async -> Bool {
131
+ await withPlayerQueueNoThrow {
132
+ var combined = self.playNextStack + self.upNextQueue
133
+ guard let fromIdx = combined.firstIndex(where: { $0.id == trackId }) else { return false }
134
+ let track = combined.remove(at: fromIdx)
135
+ let clamped = newIndex.clamped(to: 0...combined.count)
136
+ combined.insert(track, at: clamped)
137
+ let pnSize = self.playNextStack.count
138
+ self.playNextStack = Array(combined.prefix(pnSize))
139
+ self.upNextQueue = Array(combined.dropFirst(pnSize))
140
+ if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
141
+ self.notifyTemporaryQueueChange()
142
+ return true
143
+ }
144
+ }
145
+
146
+ func getPlayNextQueue() async -> [TrackItem] {
147
+ await withPlayerQueueNoThrow { self.playNextStack }
148
+ }
149
+
150
+ func getUpNextQueue() async -> [TrackItem] {
151
+ await withPlayerQueueNoThrow { self.upNextQueue }
152
+ }
153
+
154
+ func findTrackById(_ trackId: String) -> TrackItem? {
155
+ if let t = currentTracks.first(where: { $0.id == trackId }) { return t }
156
+ for playlist in playlistManager.getAllPlaylists() {
157
+ if let t = playlist.tracks.first(where: { $0.id == trackId }) { return t }
158
+ }
159
+ return nil
160
+ }
161
+ }
162
+
163
+ private extension Comparable {
164
+ func clamped(to range: ClosedRange<Self>) -> Self {
165
+ min(max(self, range.lowerBound), range.upperBound)
166
+ }
167
+ }