react-native-nitro-player 1.6.0-alpha.1 → 1.7.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 (86) hide show
  1. package/README.md +6 -1
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +9 -1
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +23 -3
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +23 -3
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +34 -17
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +6 -0
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +78 -7
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +22 -1
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
  22. package/android/src/main/res/values/strings.xml +7 -0
  23. package/ios/HybridDownloadManager.swift +13 -3
  24. package/ios/HybridTrackPlayer.swift +8 -2
  25. package/ios/core/TrackPlayerCast.swift +32 -1
  26. package/ios/core/TrackPlayerCore.swift +19 -3
  27. package/ios/core/TrackPlayerListener.swift +15 -16
  28. package/ios/core/TrackPlayerNotify.swift +11 -11
  29. package/ios/core/TrackPlayerPlayback.swift +54 -16
  30. package/ios/core/TrackPlayerQueueBuild.swift +14 -2
  31. package/ios/core/TrackPlayerTempQueue.swift +1 -0
  32. package/ios/core/TrackPlayerUrlLoader.swift +11 -3
  33. package/ios/download/DownloadDatabase.swift +16 -5
  34. package/ios/download/DownloadFileManager.swift +12 -15
  35. package/ios/download/DownloadManagerCore.swift +61 -16
  36. package/ios/equalizer/EqualizerCore.swift +37 -9
  37. package/ios/media/MediaSessionManager.swift +89 -4
  38. package/ios/playlist/PlaylistManager.swift +47 -46
  39. package/ios/queue/HybridPlayerQueue.swift +28 -7
  40. package/lib/hooks/callbackManager.d.ts +9 -0
  41. package/lib/hooks/callbackManager.js +33 -0
  42. package/lib/hooks/useActualQueue.js +18 -6
  43. package/lib/hooks/useAndroidAutoConnection.js +2 -4
  44. package/lib/hooks/useAudioDevices.js +21 -24
  45. package/lib/hooks/useDownloadActions.js +33 -11
  46. package/lib/hooks/useDownloadProgress.js +20 -14
  47. package/lib/hooks/useDownloadStorage.js +1 -1
  48. package/lib/hooks/useNowPlaying.js +32 -6
  49. package/lib/hooks/useOnSeek.js +2 -2
  50. package/lib/hooks/usePlaylist.js +6 -15
  51. package/lib/specs/TrackPlayer.nitro.d.ts +6 -2
  52. package/lib/types/PlayerQueue.d.ts +8 -0
  53. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  54. package/nitrogen/generated/android/c++/JFunc_void_TrackItem.hpp +85 -0
  55. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
  56. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  57. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TrackItem.kt +80 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
  61. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  62. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +22 -0
  63. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -0
  64. package/nitrogen/generated/ios/swift/Func_void_TrackItem.swift +46 -0
  65. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  66. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  67. package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
  68. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  69. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
  70. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
  71. package/package.json +1 -1
  72. package/src/hooks/callbackManager.ts +44 -0
  73. package/src/hooks/useActualQueue.ts +18 -6
  74. package/src/hooks/useAndroidAutoConnection.ts +6 -6
  75. package/src/hooks/useAudioDevices.ts +23 -24
  76. package/src/hooks/useDownloadActions.ts +35 -11
  77. package/src/hooks/useDownloadProgress.ts +22 -19
  78. package/src/hooks/useDownloadStorage.ts +4 -3
  79. package/src/hooks/useNowPlaying.ts +34 -6
  80. package/src/hooks/useOnSeek.ts +2 -2
  81. package/src/hooks/usePlaylist.ts +6 -14
  82. package/src/specs/TrackPlayer.nitro.ts +6 -2
  83. package/src/types/PlayerQueue.ts +8 -0
  84. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
  85. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
  86. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
@@ -169,12 +169,14 @@ extension TrackPlayerCore {
169
169
  func playerItemDidPlayToEndTimeInternal(_ notification: Notification) {
170
170
  NitroPlayerLogger.log("TrackPlayerCore", "\n🏁 Track finished playing")
171
171
  guard let finishedItem = notification.object as? AVPlayerItem else { return }
172
+ // Items without our trackId belong to another AVPlayer in the host app
173
+ guard finishedItem.trackId != nil else { return }
172
174
 
173
175
  // 1. TRACK repeat — handle FIRST, before any temp-track removal
174
- if currentRepeatMode == .track {
176
+ if currentRepeatMode == .track, finishedItem === player?.currentItem {
175
177
  NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — seeking to zero and replaying")
176
178
  player?.seek(to: .zero)
177
- player?.play()
179
+ player?.rate = Float(currentPlaybackSpeed)
178
180
  return // do not remove temp tracks, do not notify track change (same track looping)
179
181
  }
180
182
 
@@ -201,14 +203,18 @@ extension TrackPlayerCore {
201
203
  }
202
204
 
203
205
  @objc func playerItemFailedToPlayToEndTime(_ notification: Notification) {
204
- if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
206
+ guard (notification.object as? AVPlayerItem)?.trackId != nil else { return }
207
+ guard let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
208
+ else { return }
209
+ playerQueue.async { [weak self] in
205
210
  NitroPlayerLogger.log("TrackPlayerCore", "❌ Playback failed - \(error)")
206
- notifyPlaybackStateChange(.stopped, .error)
211
+ self?.notifyPlaybackStateChange(.stopped, .error)
207
212
  }
208
213
  }
209
214
 
210
215
  @objc func playerItemNewErrorLogEntry(_ notification: Notification) {
211
- guard let item = notification.object as? AVPlayerItem, let errorLog = item.errorLog() else { return }
216
+ guard let item = notification.object as? AVPlayerItem, item.trackId != nil,
217
+ let errorLog = item.errorLog() else { return }
212
218
  for event in errorLog.events ?? [] {
213
219
  NitroPlayerLogger.log("TrackPlayerCore", "❌ Error log - \(event.errorComment ?? "Unknown error") - Code: \(event.errorStatusCode)")
214
220
  }
@@ -220,6 +226,7 @@ extension TrackPlayerCore {
220
226
  @objc func playerItemTimeJumped(_ notification: Notification) {
221
227
  playerQueue.async { [weak self] in
222
228
  guard let self, let player = self.player, let currentItem = player.currentItem else { return }
229
+ guard (notification.object as? AVPlayerItem) === currentItem else { return }
223
230
  let position = currentItem.currentTime().seconds
224
231
  let duration = currentItem.duration.seconds
225
232
  NitroPlayerLogger.log("TrackPlayerCore", "🎯 Time jumped (seek detected) - position: \(Int(position))s")
@@ -301,7 +308,7 @@ extension TrackPlayerCore {
301
308
  lastItem = item
302
309
  }
303
310
  currentTrackIndex = 0
304
- player.play()
311
+ player.rate = Float(currentPlaybackSpeed)
305
312
 
306
313
  if let firstTrack = currentTracks.first {
307
314
  notifyTrackChange(firstTrack, .repeat)
@@ -454,16 +461,8 @@ extension TrackPlayerCore {
454
461
  self?.setupBoundaryTimeObserver()
455
462
  // First item is buffered and ready — disable stall waiting for gapless inter-track transitions
456
463
  self?.player?.automaticallyWaitsToMinimizeStalling = false
457
- // Update now playing info now that duration is available (capture on playerQueue first)
458
- let state = self?.getStateInternal()
459
- let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
460
- let track = self?.getCurrentTrack()
461
- DispatchQueue.main.async {
462
- if let track = track, let state = state {
463
- self?.mediaSessionManager?.updateFromPlayerQueue(
464
- track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
465
- }
466
- }
464
+ // Update now playing info now that duration is available (captured on playerQueue)
465
+ self?.refreshMediaSession()
467
466
  } else if item.status == .failed {
468
467
  NitroPlayerLogger.log("TrackPlayerCore",
469
468
  "❌ Item failed — \(item.error?.localizedDescription ?? "unknown error")")
@@ -13,24 +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 metrics now (on playerQueue), pass pre-computed values to main.
17
- // Metrics, not the queue itself — the media session only needs count and position.
18
- let state = getStateInternal()
19
- let metrics = actualQueueMetrics()
20
- DispatchQueue.main.async { [weak self] in
21
- self?.mediaSessionManager?.updateFromPlayerQueue(
22
- track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
23
- }
16
+ refreshMediaSession(track: track)
24
17
  }
25
18
 
26
19
  func notifyPlaybackStateChange(_ state: TrackPlayerState, _ reason: Reason?) {
27
20
  onPlaybackStateChangeListeners.forEach { $0(state, reason) }
28
- guard let track = getCurrentTrack() else { return }
29
- let playerState = getStateInternal()
21
+ refreshMediaSession()
22
+ }
23
+
24
+ /// Must run on playerQueue — values are captured here and handed to main pre-computed
25
+ func refreshMediaSession(track providedTrack: TrackItem? = nil) {
26
+ guard let track = providedTrack ?? getCurrentTrack() else { return }
27
+ let state = getStateInternal()
30
28
  let metrics = actualQueueMetrics()
29
+ let playbackSpeed = currentPlaybackSpeed
31
30
  DispatchQueue.main.async { [weak self] in
32
31
  self?.mediaSessionManager?.updateFromPlayerQueue(
33
- track: track, state: playerState, queueCount: metrics.count, positionInQueue: metrics.position)
32
+ track: track, state: state, queueCount: metrics.count,
33
+ positionInQueue: metrics.position, playbackSpeed: playbackSpeed)
34
34
  }
35
35
  }
36
36
 
@@ -98,33 +98,49 @@ extension TrackPlayerCore {
98
98
 
99
99
  func configureOnQueue(
100
100
  androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
101
- showInNotification: Bool?, lookaheadCount: Int?
101
+ showInNotification: Bool?, lookaheadCount: Int?,
102
+ remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?
102
103
  ) {
103
104
  if let la = lookaheadCount {
104
105
  self.lookaheadCount = la
105
106
  NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(la)")
106
107
  }
108
+ let playbackSpeed = currentPlaybackSpeed
107
109
  DispatchQueue.main.async { [weak self] in
108
110
  self?.mediaSessionManager?.configure(
109
111
  androidAutoEnabled: androidAutoEnabled,
110
112
  carPlayEnabled: carPlayEnabled,
111
- showInNotification: showInNotification
113
+ showInNotification: showInNotification,
114
+ remoteSkipForwardInterval: remoteSkipForwardInterval,
115
+ remoteSkipBackwardInterval: remoteSkipBackwardInterval,
116
+ playbackSpeed: playbackSpeed
112
117
  )
113
118
  }
114
119
  }
115
120
  func configure(
116
121
  androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
117
- showInNotification: Bool?, lookaheadCount: Int?
122
+ showInNotification: Bool?, lookaheadCount: Int?,
123
+ remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?
118
124
  ) async {
119
125
  await withPlayerQueueNoThrow {
120
126
  self.configureOnQueue(
121
127
  androidAutoEnabled: androidAutoEnabled, carPlayEnabled: carPlayEnabled,
122
- showInNotification: showInNotification, lookaheadCount: lookaheadCount)
128
+ showInNotification: showInNotification, lookaheadCount: lookaheadCount,
129
+ remoteSkipForwardInterval: remoteSkipForwardInterval,
130
+ remoteSkipBackwardInterval: remoteSkipBackwardInterval)
123
131
  }
124
132
  }
125
133
 
126
- func setPlaybackSpeedOnQueue(_ speed: Double) {
134
+ func setPlaybackSpeedOnQueue(_ speed: Double) throws {
135
+ guard speed.isFinite, speed > 0 else {
136
+ throw NSError(
137
+ domain: "NitroPlayer",
138
+ code: -1,
139
+ userInfo: [NSLocalizedDescriptionKey: "Speed must be finite and greater than 0"]
140
+ )
141
+ }
127
142
  currentPlaybackSpeed = speed
143
+ defer { refreshMediaSession() }
128
144
  if isCasting {
129
145
  castManager?.setPlaybackRate(Float(speed))
130
146
  return
@@ -134,8 +150,30 @@ extension TrackPlayerCore {
134
150
  player.rate = Float(speed)
135
151
  }
136
152
  }
137
- func setPlaybackSpeed(_ speed: Double) async {
138
- await withPlayerQueueNoThrow { self.setPlaybackSpeedOnQueue(speed) }
153
+ func setPlaybackSpeed(_ speed: Double) async throws {
154
+ try await withPlayerQueue { try self.setPlaybackSpeedOnQueue(speed) }
155
+ }
156
+
157
+ func seekByOnQueue(offset: Double) {
158
+ if isCasting {
159
+ let position = castManager?.lastKnownRemotePosition ?? 0
160
+ let duration = castManager?.lastKnownRemoteDuration ?? 0
161
+ var target = max(0, position + offset)
162
+ if duration > 0, duration.isFinite { target = min(target, duration) }
163
+ castManager?.seek(to: target)
164
+ return
165
+ }
166
+ guard let player = self.player else { return }
167
+ let position = player.currentTime().seconds
168
+ guard position.isFinite else { return }
169
+ var target = max(0, position + offset)
170
+ if let duration = player.currentItem?.duration.seconds, duration > 0, duration.isFinite {
171
+ target = min(target, duration)
172
+ }
173
+ seekInternal(position: target)
174
+ }
175
+ func seekBy(offset: Double) async {
176
+ await withPlayerQueueNoThrow { self.seekByOnQueue(offset: offset) }
139
177
  }
140
178
 
141
179
  func getPlaybackSpeed() async -> Double {
@@ -150,6 +188,7 @@ extension TrackPlayerCore {
150
188
 
151
189
  func playInternal() {
152
190
  NitroPlayerLogger.log("TrackPlayerCore", "▶️ play() called")
191
+ activateAudioSessionIfNeeded()
153
192
  self.intendedToPlay = true
154
193
  // An explicit play() is the ground truth for intent — clear any stale
155
194
  // interruption flag so a missed AVAudioSession `.ended` can't permanently
@@ -187,7 +226,7 @@ extension TrackPlayerCore {
187
226
  guard let player = self.player else { return }
188
227
  self.isManuallySeeked = true
189
228
  let time = CMTime(seconds: position, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
190
- player.seek(to: time) { [weak self] completed in
229
+ player.seek(to: time) { _ in
191
230
  // HackFix I dont know how to fix this, but it works.
192
231
  let rate = Double(player.rate)
193
232
  DispatchQueue.main.async {
@@ -197,10 +236,8 @@ extension TrackPlayerCore {
197
236
  MPNowPlayingInfoCenter.default().nowPlayingInfo = info
198
237
  }
199
238
  }
200
- if completed {
201
- let duration = player.currentItem?.duration.seconds ?? 0.0
202
- self?.notifySeek(position, duration)
203
- }
239
+ // No notifySeek here — the same seek fires AVPlayerItemTimeJumped,
240
+ // whose handler already emits it; calling both doubled every onSeek.
204
241
  }
205
242
  }
206
243
 
@@ -240,11 +277,12 @@ extension TrackPlayerCore {
240
277
 
241
278
  if queuePlayer.items().count > 1 {
242
279
  queuePlayer.advanceToNextItem()
280
+ } else if currentRepeatMode == .playlist, !currentTracks.isEmpty {
281
+ // Manual skip at the last track wraps, matching natural-end behavior
282
+ _ = skipToIndexInternal(index: 0)
243
283
  } else {
244
- queuePlayer.pause()
245
- self.intendedToPlay = false
246
- self.isRecoveringFromStall = false
247
- self.notifyPlaybackStateChange(.stopped, .end)
284
+ // No next track — no-op and keep playing, matching Android
285
+ NitroPlayerLogger.log("TrackPlayerCore", "⏭️ skipToNext at last track — nothing to advance to")
248
286
  }
249
287
 
250
288
  checkUpcomingTracksForUrls(lookahead: lookaheadCount)
@@ -85,6 +85,7 @@ extension TrackPlayerCore {
85
85
  // Clear old preloaded assets when loading new queue
86
86
  for asset in preloadedAssets.values { asset.cancelLoading() }
87
87
  preloadedAssets.removeAll()
88
+ preloadGeneration += 1
88
89
 
89
90
  guard let existingPlayer = self.player else {
90
91
  NitroPlayerLogger.log("TrackPlayerCore", "❌ No player available")
@@ -262,7 +263,7 @@ extension TrackPlayerCore {
262
263
 
263
264
  self.preloadUpcomingTracks(from: index + 1)
264
265
 
265
- if wasPlaying { player.play() }
266
+ if wasPlaying { player.rate = Float(currentPlaybackSpeed) }
266
267
  return true
267
268
  }
268
269
 
@@ -569,6 +570,7 @@ extension TrackPlayerCore {
569
570
  // Swift Array/Dictionary are not safe for concurrent access.
570
571
  let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
571
572
  let alreadyPreloaded = Set(preloadedAssets.keys)
573
+ let generation = preloadGeneration
572
574
  let endIndex = min(startIndex + Constants.gaplessPreloadCount, currentTracks.count)
573
575
  guard startIndex >= 0, startIndex < endIndex else { return }
574
576
  let candidates = Array(currentTracks[startIndex..<endIndex])
@@ -607,7 +609,17 @@ extension TrackPlayerCore {
607
609
 
608
610
  if allKeysLoaded {
609
611
  self?.playerQueue.async {
610
- self?.preloadedAssets[track.id] = asset
612
+ guard let self else { return }
613
+ // Stale completion: the queue was torn down (or this track left it)
614
+ // while the asset was still loading — publishing would hand a dead
615
+ // or wrong-URL asset to createGaplessPlayerItem
616
+ guard self.preloadGeneration == generation,
617
+ self.currentTracks.contains(where: { $0.id == track.id })
618
+ else {
619
+ asset.cancelLoading()
620
+ return
621
+ }
622
+ self.preloadedAssets[track.id] = asset
611
623
  NitroPlayerLogger.log("TrackPlayerCore", "🎯 Preloaded asset for upcoming track: \(track.title)")
612
624
  }
613
625
  }
@@ -55,6 +55,7 @@ extension TrackPlayerCore {
55
55
  self.currentTracks = playlist.tracks
56
56
  for asset in self.preloadedAssets.values { asset.cancelLoading() }
57
57
  self.preloadedAssets.removeAll()
58
+ self.preloadGeneration += 1
58
59
  _ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
59
60
  }
60
61
  self.emitStateChange()
@@ -79,7 +79,9 @@ extension TrackPlayerCore {
79
79
  let affectedPlaylists = self.playlistManager.updateTracks(tracks: safeTracks)
80
80
 
81
81
  // Replace the current AVPlayerItem when its URL just resolved (local only — cast reloads below instead).
82
- if !self.isCasting, let update = currentTrack, currentTrackIsEmpty, !update.url.isEmpty {
82
+ // Look up the FRESH track — the pre-update snapshot's URL is empty by definition here.
83
+ if !self.isCasting, currentTrackIsEmpty,
84
+ let update = safeTracks.first(where: { $0.id == currentTrackId }), !update.url.isEmpty {
83
85
  NitroPlayerLogger.log("TrackPlayerCore",
84
86
  "🔄 Replacing current AVPlayerItem for track with resolved URL: \(update.id)")
85
87
  if let newItem = self.createGaplessPlayerItem(for: update, isPreload: false) {
@@ -131,14 +133,20 @@ extension TrackPlayerCore {
131
133
  "🔄 No current item — full queue rebuild from currentTrackIndex \(self.currentTrackIndex)")
132
134
  self.removeAllItemsCancellingLoads(player)
133
135
  var lastItem: AVPlayerItem? = nil
134
- for (offset, track) in self.currentTracks[max(0, self.currentTrackIndex)...].enumerated() {
136
+ // Window like every other rebuild path — materializing the whole remaining
137
+ // playlist builds thousands of AVPlayerItems on playerQueue at once.
138
+ let windowed = self.currentTracks[max(0, self.currentTrackIndex)...]
139
+ .prefix(1 + Constants.queueWindowSize)
140
+ for (offset, track) in windowed.enumerated() {
135
141
  let isPreload = offset < Constants.gaplessPreloadCount
136
142
  if let newItem = self.createGaplessPlayerItem(for: track, isPreload: isPreload) {
137
143
  player.insert(newItem, after: lastItem)
138
144
  lastItem = newItem
139
145
  }
140
146
  }
141
- player.play()
147
+ if self.intendedToPlay {
148
+ player.rate = Float(self.currentPlaybackSpeed)
149
+ }
142
150
  self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
143
151
  } else {
144
152
  // A current AVPlayerItem already exists — preserve it and only rebuild upcoming items.
@@ -150,6 +150,13 @@ final class DownloadDatabase {
150
150
 
151
151
  func getDownloadedTrack(trackId: String) -> DownloadedTrack? {
152
152
  return queue.sync {
153
+ _getDownloadedTrackUnsafe(trackId: trackId)
154
+ }
155
+ }
156
+
157
+ /// Must be called from within `queue` — a nested `queue.sync` here would
158
+ /// deadlock against pending barrier writers on this concurrent queue.
159
+ private func _getDownloadedTrackUnsafe(trackId: String) -> DownloadedTrack? {
153
160
  NitroPlayerLogger.log("DownloadDatabase", "🔍 DownloadDatabase.getDownloadedTrack() for trackId: \(trackId)")
154
161
  NitroPlayerLogger.log("DownloadDatabase", " Total records in memory: \(downloadedTracks.count)")
155
162
  NitroPlayerLogger.log("DownloadDatabase", " Available trackIds: \(Array(downloadedTracks.keys))")
@@ -175,7 +182,6 @@ final class DownloadDatabase {
175
182
 
176
183
  NitroPlayerLogger.log("DownloadDatabase", " ✅ File exists, returning track")
177
184
  return recordToDownloadedTrack(record)
178
- }
179
185
  }
180
186
 
181
187
  func getAllDownloadedTracks() -> [DownloadedTrack] {
@@ -214,7 +220,13 @@ final class DownloadDatabase {
214
220
  }
215
221
 
216
222
  func getDownloadedPlaylist(playlistId: String) -> DownloadedPlaylist? {
217
- return queue.sync { () -> DownloadedPlaylist? in
223
+ return queue.sync {
224
+ _getDownloadedPlaylistUnsafe(playlistId: playlistId)
225
+ }
226
+ }
227
+
228
+ /// Must be called from within `queue` — see `_getDownloadedTrackUnsafe`.
229
+ private func _getDownloadedPlaylistUnsafe(playlistId: String) -> DownloadedPlaylist? {
218
230
  guard let trackIds = playlistTracks[playlistId], !trackIds.isEmpty else { return nil }
219
231
  guard let playlistModel = PlaylistManager.shared.getPlaylist(playlistId: playlistId) else {
220
232
  return nil
@@ -224,7 +236,7 @@ final class DownloadDatabase {
224
236
  var totalSize: Double = 0
225
237
 
226
238
  for trackId in trackIds {
227
- if let track = getDownloadedTrack(trackId: trackId) {
239
+ if let track = _getDownloadedTrackUnsafe(trackId: trackId) {
228
240
  downloadedTracks.append(track)
229
241
  totalSize += track.fileSize
230
242
  }
@@ -243,7 +255,6 @@ final class DownloadDatabase {
243
255
  ?? Date().timeIntervalSince1970,
244
256
  isComplete: isComplete
245
257
  )
246
- }
247
258
  }
248
259
 
249
260
  func getAllDownloadedPlaylists() -> [DownloadedPlaylist] {
@@ -251,7 +262,7 @@ final class DownloadDatabase {
251
262
  var playlists: [DownloadedPlaylist] = []
252
263
 
253
264
  for playlistId in playlistTracks.keys {
254
- if let playlist = getDownloadedPlaylist(playlistId: playlistId) {
265
+ if let playlist = _getDownloadedPlaylistUnsafe(playlistId: playlistId) {
255
266
  playlists.append(playlist)
256
267
  }
257
268
  }
@@ -24,33 +24,30 @@ final class DownloadFileManager {
24
24
 
25
25
  private let fileManager = FileManager.default
26
26
 
27
- private lazy var privateDownloadsDirectory: URL = {
27
+ // `let` computed once in the singleton's init — Swift `lazy var` initialization
28
+ // is not atomic and these were first-touched from multiple queues.
29
+ private let privateDownloadsDirectory: URL
30
+ private let publicDownloadsDirectory: URL
31
+
32
+ // MARK: - Initialization
33
+
34
+ private init() {
28
35
  let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
29
- let downloadsPath = documentsPath.appendingPathComponent(Self.privateDownloadsFolderName)
30
36
 
37
+ let downloadsPath = documentsPath.appendingPathComponent(Self.privateDownloadsFolderName)
31
38
  if !fileManager.fileExists(atPath: downloadsPath.path) {
32
39
  try? fileManager.createDirectory(at: downloadsPath, withIntermediateDirectories: true)
33
40
  }
41
+ privateDownloadsDirectory = downloadsPath
34
42
 
35
- return downloadsPath
36
- }()
37
-
38
- private lazy var publicDownloadsDirectory: URL = {
39
43
  // On iOS, we can't write to a truly public folder, but we can use the shared container
40
44
  // or the Documents folder which is accessible via Files app
41
- let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
42
45
  let publicPath = documentsPath.appendingPathComponent(Self.publicDownloadsFolderName)
43
-
44
46
  if !fileManager.fileExists(atPath: publicPath.path) {
45
47
  try? fileManager.createDirectory(at: publicPath, withIntermediateDirectories: true)
46
48
  }
47
-
48
- return publicPath
49
- }()
50
-
51
- // MARK: - Initialization
52
-
53
- private init() {}
49
+ publicDownloadsDirectory = publicPath
50
+ }
54
51
 
55
52
  // MARK: - File Operations
56
53
 
@@ -42,7 +42,7 @@ final class DownloadManagerCore: NSObject {
42
42
  withIdentifier: Self.backgroundSessionIdentifier)
43
43
  configuration.isDiscretionary = false
44
44
  configuration.sessionSendsLaunchEvents = true
45
- configuration.allowsCellularAccess = !config.wifiOnlyDownloads!
45
+ configuration.allowsCellularAccess = !(config.wifiOnlyDownloads ?? false)
46
46
  return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
47
47
  }()
48
48
 
@@ -66,9 +66,11 @@ final class DownloadManagerCore: NSObject {
66
66
 
67
67
  // MARK: - Callbacks
68
68
 
69
- private var progressCallbacks: [(DownloadProgress) -> Void] = []
70
- private var stateChangeCallbacks: [(String, String, DownloadState, DownloadError?) -> Void] = []
71
- private var completeCallbacks: [(DownloadedTrack) -> Void] = []
69
+ // Keyed so HybridDownloadManager.deinit can remove its registrations instead
70
+ // of leaking closures into dead JS runtimes across reloads
71
+ private var progressCallbacks: [(UUID, (DownloadProgress) -> Void)] = []
72
+ private var stateChangeCallbacks: [(UUID, (String, String, DownloadState, DownloadError?) -> Void)] = []
73
+ private var completeCallbacks: [(UUID, (DownloadedTrack) -> Void)] = []
72
74
 
73
75
  // MARK: - Thread Safety
74
76
 
@@ -180,8 +182,10 @@ final class DownloadManagerCore: NSObject {
180
182
  guard let task = self.activeTasks[downloadId] else { return }
181
183
 
182
184
  task.cancel(byProducingResumeData: { resumeData in
183
- // Store resume data for later
184
- self.taskMetadata[downloadId]?.resumeData = resumeData
185
+ // Runs on the URLSession delegate queue — hop back for the shared dict
186
+ self.queue.async(flags: .barrier) {
187
+ self.taskMetadata[downloadId]?.resumeData = resumeData
188
+ }
185
189
  })
186
190
 
187
191
  self.taskMetadata[downloadId]?.state = .paused
@@ -264,7 +268,9 @@ final class DownloadManagerCore: NSObject {
264
268
  private func _pauseDownloadUnsafe(downloadId: String) {
265
269
  guard let task = self.activeTasks[downloadId] else { return }
266
270
  task.cancel(byProducingResumeData: { resumeData in
267
- self.taskMetadata[downloadId]?.resumeData = resumeData
271
+ self.queue.async(flags: .barrier) {
272
+ self.taskMetadata[downloadId]?.resumeData = resumeData
273
+ }
268
274
  })
269
275
  self.taskMetadata[downloadId]?.state = .paused
270
276
  if let trackId = self.taskMetadata[downloadId]?.trackId {
@@ -505,23 +511,50 @@ final class DownloadManagerCore: NSObject {
505
511
 
506
512
  // MARK: - Callbacks
507
513
 
508
- func addProgressCallback(_ callback: @escaping (DownloadProgress) -> Void) {
514
+ @discardableResult
515
+ func addProgressCallback(_ callback: @escaping (DownloadProgress) -> Void) -> UUID {
516
+ let id = UUID()
517
+ queue.async(flags: .barrier) {
518
+ self.progressCallbacks.append((id, callback))
519
+ }
520
+ return id
521
+ }
522
+
523
+ func removeProgressCallback(id: UUID) {
509
524
  queue.async(flags: .barrier) {
510
- self.progressCallbacks.append(callback)
525
+ self.progressCallbacks.removeAll { $0.0 == id }
511
526
  }
512
527
  }
513
528
 
529
+ @discardableResult
514
530
  func addStateChangeCallback(
515
531
  _ callback: @escaping (String, String, DownloadState, DownloadError?) -> Void
516
- ) {
532
+ ) -> UUID {
533
+ let id = UUID()
534
+ queue.async(flags: .barrier) {
535
+ self.stateChangeCallbacks.append((id, callback))
536
+ }
537
+ return id
538
+ }
539
+
540
+ func removeStateChangeCallback(id: UUID) {
541
+ queue.async(flags: .barrier) {
542
+ self.stateChangeCallbacks.removeAll { $0.0 == id }
543
+ }
544
+ }
545
+
546
+ @discardableResult
547
+ func addCompleteCallback(_ callback: @escaping (DownloadedTrack) -> Void) -> UUID {
548
+ let id = UUID()
517
549
  queue.async(flags: .barrier) {
518
- self.stateChangeCallbacks.append(callback)
550
+ self.completeCallbacks.append((id, callback))
519
551
  }
552
+ return id
520
553
  }
521
554
 
522
- func addCompleteCallback(_ callback: @escaping (DownloadedTrack) -> Void) {
555
+ func removeCompleteCallback(id: UUID) {
523
556
  queue.async(flags: .barrier) {
524
- self.completeCallbacks.append(callback)
557
+ self.completeCallbacks.removeAll { $0.0 == id }
525
558
  }
526
559
  }
527
560
 
@@ -726,8 +759,9 @@ final class DownloadManagerCore: NSObject {
726
759
  }
727
760
 
728
761
  private func notifyProgress(_ progress: DownloadProgress) {
762
+ let callbacks = self.progressCallbacks
729
763
  DispatchQueue.main.async {
730
- for callback in self.progressCallbacks {
764
+ for (_, callback) in callbacks {
731
765
  callback(progress)
732
766
  }
733
767
  }
@@ -736,16 +770,18 @@ final class DownloadManagerCore: NSObject {
736
770
  private func notifyStateChange(
737
771
  downloadId: String, trackId: String, state: DownloadState, error: DownloadError?
738
772
  ) {
773
+ let callbacks = self.stateChangeCallbacks
739
774
  DispatchQueue.main.async {
740
- for callback in self.stateChangeCallbacks {
775
+ for (_, callback) in callbacks {
741
776
  callback(downloadId, trackId, state, error)
742
777
  }
743
778
  }
744
779
  }
745
780
 
746
781
  private func notifyComplete(_ downloadedTrack: DownloadedTrack) {
782
+ let callbacks = self.completeCallbacks
747
783
  DispatchQueue.main.async {
748
- for callback in self.completeCallbacks {
784
+ for (_, callback) in callbacks {
749
785
  callback(downloadedTrack)
750
786
  }
751
787
  }
@@ -907,6 +943,14 @@ extension DownloadManagerCore: URLSessionDownloadDelegate {
907
943
  self.taskMetadata[downloadId]?.totalBytes =
908
944
  totalBytesExpectedToWrite > 0 ? Double(totalBytesExpectedToWrite) : nil
909
945
 
946
+ // ~4 Hz per download — didWriteData fires many times per second per task
947
+ // and every emission is a main-queue hop plus a JS event
948
+ let now = Date().timeIntervalSince1970
949
+ if let last = self.taskMetadata[downloadId]?.lastProgressEmitAt, now - last < 0.25 {
950
+ return
951
+ }
952
+ self.taskMetadata[downloadId]?.lastProgressEmitAt = now
953
+
910
954
  let progress = DownloadProgress(
911
955
  trackId: trackId,
912
956
  downloadId: downloadId,
@@ -1010,6 +1054,7 @@ private struct DownloadTaskMetadata {
1010
1054
  var bytesDownloaded: Double = 0
1011
1055
  var totalBytes: Double?
1012
1056
  var error: DownloadError?
1057
+ var lastProgressEmitAt: TimeInterval = 0
1013
1058
 
1014
1059
  func toDownloadTask() -> DownloadTask {
1015
1060
  let progress = DownloadProgress(