react-native-nitro-player 1.6.0 → 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 (71) 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/connection/AndroidAutoConnectionDetector.kt +22 -22
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +21 -9
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +7 -1
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
  21. package/android/src/main/res/values/strings.xml +7 -0
  22. package/ios/HybridDownloadManager.swift +13 -3
  23. package/ios/HybridTrackPlayer.swift +4 -2
  24. package/ios/core/TrackPlayerCast.swift +32 -1
  25. package/ios/core/TrackPlayerCore.swift +19 -3
  26. package/ios/core/TrackPlayerListener.swift +15 -16
  27. package/ios/core/TrackPlayerNotify.swift +11 -11
  28. package/ios/core/TrackPlayerPlayback.swift +54 -16
  29. package/ios/core/TrackPlayerQueueBuild.swift +14 -2
  30. package/ios/core/TrackPlayerTempQueue.swift +1 -0
  31. package/ios/core/TrackPlayerUrlLoader.swift +11 -3
  32. package/ios/download/DownloadDatabase.swift +16 -5
  33. package/ios/download/DownloadFileManager.swift +12 -15
  34. package/ios/download/DownloadManagerCore.swift +61 -16
  35. package/ios/equalizer/EqualizerCore.swift +37 -9
  36. package/ios/media/MediaSessionManager.swift +89 -4
  37. package/ios/playlist/PlaylistManager.swift +47 -46
  38. package/ios/queue/HybridPlayerQueue.swift +28 -7
  39. package/lib/hooks/callbackManager.d.ts +9 -0
  40. package/lib/hooks/callbackManager.js +33 -0
  41. package/lib/hooks/useActualQueue.js +18 -6
  42. package/lib/hooks/useAndroidAutoConnection.js +2 -4
  43. package/lib/hooks/useAudioDevices.js +21 -24
  44. package/lib/hooks/useDownloadActions.js +33 -11
  45. package/lib/hooks/useDownloadProgress.js +20 -14
  46. package/lib/hooks/useDownloadStorage.js +1 -1
  47. package/lib/hooks/useNowPlaying.js +32 -6
  48. package/lib/hooks/useOnSeek.js +2 -2
  49. package/lib/hooks/usePlaylist.js +6 -15
  50. package/lib/specs/TrackPlayer.nitro.d.ts +1 -2
  51. package/lib/types/PlayerQueue.d.ts +8 -0
  52. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
  54. package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
  55. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
  56. package/package.json +1 -1
  57. package/src/hooks/callbackManager.ts +44 -0
  58. package/src/hooks/useActualQueue.ts +18 -6
  59. package/src/hooks/useAndroidAutoConnection.ts +6 -6
  60. package/src/hooks/useAudioDevices.ts +23 -24
  61. package/src/hooks/useDownloadActions.ts +35 -11
  62. package/src/hooks/useDownloadProgress.ts +22 -19
  63. package/src/hooks/useDownloadStorage.ts +4 -3
  64. package/src/hooks/useNowPlaying.ts +34 -6
  65. package/src/hooks/useOnSeek.ts +2 -2
  66. package/src/hooks/usePlaylist.ts +6 -14
  67. package/src/specs/TrackPlayer.nitro.ts +1 -2
  68. package/src/types/PlayerQueue.ts +8 -0
  69. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
  70. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
  71. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
@@ -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(
@@ -28,6 +28,27 @@ class EqualizerCore {
28
28
 
29
29
  // Current gains storage - internal so TapContext can access
30
30
  private(set) var currentGains: [Double] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
31
+
32
+ // Guards currentGains/gainsDirty between the JS thread (slider writes) and the
33
+ // MTAudioProcessingTap render thread — unsynchronized Swift Array access is a
34
+ // CoW data race (torn coefficients, loud glitches, rare crashes)
35
+ private var gainsLock = os_unfair_lock_s()
36
+
37
+ func withGainsLock<T>(_ body: () -> T) -> T {
38
+ os_unfair_lock_lock(&gainsLock)
39
+ defer { os_unfair_lock_unlock(&gainsLock) }
40
+ return body()
41
+ }
42
+
43
+ /// Render-thread accessor: returns a snapshot of the gains and clears the dirty
44
+ /// flag atomically, or nil when nothing changed.
45
+ func consumeDirtyGains() -> [Double]? {
46
+ return withGainsLock {
47
+ guard gainsDirty else { return nil }
48
+ gainsDirty = false
49
+ return currentGains
50
+ }
51
+ }
31
52
  private var cachedBands: [EqualizerBand]?
32
53
  private var cachedCustomPresets: [EqualizerPreset]?
33
54
 
@@ -209,9 +230,11 @@ class EqualizerCore {
209
230
  guard bandIndex >= 0 && bandIndex < 10 else { return false }
210
231
 
211
232
  let clampedGain = max(-12.0, min(12.0, gainDb))
212
- currentGains[bandIndex] = clampedGain
233
+ withGainsLock {
234
+ currentGains[bandIndex] = clampedGain
235
+ gainsDirty = true
236
+ }
213
237
  cachedBands = nil
214
- gainsDirty = true
215
238
 
216
239
  currentPresetName = nil
217
240
  notifyBandChange(getBands())
@@ -226,11 +249,13 @@ class EqualizerCore {
226
249
  func setAllBandGains(_ gains: [Double]) -> Bool {
227
250
  guard gains.count == 10 else { return false }
228
251
 
229
- for i in 0..<10 {
230
- currentGains[i] = max(-12.0, min(12.0, gains[i]))
252
+ withGainsLock {
253
+ for i in 0..<10 {
254
+ currentGains[i] = max(-12.0, min(12.0, gains[i]))
255
+ }
256
+ gainsDirty = true
231
257
  }
232
258
  cachedBands = nil
233
- gainsDirty = true
234
259
 
235
260
  notifyBandChange(getBands())
236
261
  saveBandGains(currentGains)
@@ -406,7 +431,10 @@ class EqualizerCore {
406
431
  let gains = try? JSONDecoder().decode([Double].self, from: data),
407
432
  gains.count == 10
408
433
  {
409
- currentGains = gains
434
+ withGainsLock {
435
+ currentGains = gains
436
+ gainsDirty = true
437
+ }
410
438
  }
411
439
  // else: migration from 5-band or fresh install — start at flat (currentGains already zeroed)
412
440
 
@@ -478,9 +506,10 @@ private class TapContext {
478
506
  }
479
507
 
480
508
  func updateCoefficients() {
481
- guard let eqCore = eqCore else { return }
509
+ // Snapshot + dirty-clear happen atomically under the gains lock; nil means
510
+ // another consumer already took this change
511
+ guard let gains = eqCore?.consumeDirtyGains() else { return }
482
512
  let frequencies: [Float] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
483
- let gains = eqCore.currentGains
484
513
 
485
514
  for i in 0..<10 {
486
515
  filterCoeffs[i] = calculatePeakingEQCoefficients(
@@ -490,7 +519,6 @@ private class TapContext {
490
519
  sampleRate: Double(sampleRate)
491
520
  )
492
521
  }
493
- eqCore.gainsDirty = false
494
522
  }
495
523
 
496
524
  /// Calculate biquad coefficients for a peaking EQ filter