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
@@ -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
@@ -16,6 +16,8 @@ class MediaSessionManager {
16
16
 
17
17
  private enum Constants {
18
18
  static let artworkSize: CGFloat = 500.0
19
+ static let defaultRemoteSkipInterval: Double = 15.0
20
+ static let supportedPlaybackRates: [Float] = [0.5, 1.0, 1.25, 1.5, 1.75, 2.0]
19
21
  }
20
22
 
21
23
  // MARK: - Properties
@@ -24,6 +26,8 @@ class MediaSessionManager {
24
26
  private let artworkCache = NSCache<NSString, UIImage>()
25
27
 
26
28
  private var showInNotification: Bool = true
29
+ private var remoteSkipForwardInterval: Double = Constants.defaultRemoteSkipInterval
30
+ private var remoteSkipBackwardInterval: Double = Constants.defaultRemoteSkipInterval
27
31
 
28
32
  // Tracks the artwork URL currently shown so we can discard stale async loads
29
33
  private var lastArtworkUrl: String?
@@ -36,6 +40,7 @@ class MediaSessionManager {
36
40
  // per event; these two Ints are computed on playerQueue instead.
37
41
  private var cachedQueueCount: Int = 0
38
42
  private var cachedPositionInQueue: Int = -1
43
+ private var cachedPlaybackSpeed: Double = 1.0
39
44
 
40
45
  init() {
41
46
  setupRemoteCommandCenter()
@@ -48,11 +53,24 @@ class MediaSessionManager {
48
53
  func configure(
49
54
  androidAutoEnabled: Bool?,
50
55
  carPlayEnabled: Bool?,
51
- showInNotification: Bool?
56
+ showInNotification: Bool?,
57
+ remoteSkipForwardInterval: Double?,
58
+ remoteSkipBackwardInterval: Double?,
59
+ playbackSpeed: Double?
52
60
  ) {
53
61
  if let showInNotification = showInNotification {
54
62
  self.showInNotification = showInNotification
55
63
  }
64
+ if let playbackSpeed = sanitizedPositive(playbackSpeed) {
65
+ cachedPlaybackSpeed = playbackSpeed
66
+ }
67
+ if let interval = sanitizedPositive(remoteSkipForwardInterval) {
68
+ self.remoteSkipForwardInterval = interval
69
+ }
70
+ if let interval = sanitizedPositive(remoteSkipBackwardInterval) {
71
+ self.remoteSkipBackwardInterval = interval
72
+ }
73
+ updateSkipCommandIntervals()
56
74
  refresh()
57
75
  }
58
76
 
@@ -61,12 +79,14 @@ class MediaSessionManager {
61
79
  // Receives pre-computed values captured on playerQueue — no player access here.
62
80
 
63
81
  func updateFromPlayerQueue(
64
- track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int
82
+ track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int,
83
+ playbackSpeed: Double
65
84
  ) {
66
85
  cachedTrack = track
67
86
  cachedState = state
68
87
  cachedQueueCount = queueCount
69
88
  cachedPositionInQueue = positionInQueue
89
+ cachedPlaybackSpeed = sanitizedPositive(playbackSpeed) ?? 1.0
70
90
  refreshInternal()
71
91
  }
72
92
 
@@ -123,6 +143,7 @@ class MediaSessionManager {
123
143
  let currentPosition = state.currentPosition
124
144
  let safePosition = currentPosition.isNaN || currentPosition.isInfinite ? 0 : currentPosition
125
145
  let isPlaying = state.currentState == .playing
146
+ let playbackSpeed = sanitizedPositive(cachedPlaybackSpeed) ?? 1.0
126
147
 
127
148
  var nowPlayingInfo: [String: Any] = [
128
149
  MPMediaItemPropertyTitle: track.title,
@@ -130,8 +151,8 @@ class MediaSessionManager {
130
151
  MPMediaItemPropertyAlbumTitle: track.album,
131
152
  MPNowPlayingInfoPropertyElapsedPlaybackTime: safePosition,
132
153
  MPMediaItemPropertyPlaybackDuration: effectiveDuration,
133
- MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0,
134
- MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
154
+ MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? playbackSpeed : 0.0,
155
+ MPNowPlayingInfoPropertyDefaultPlaybackRate: playbackSpeed,
135
156
  MPNowPlayingInfoPropertyPlaybackQueueCount: max(1, queueCount),
136
157
  MPNowPlayingInfoPropertyPlaybackQueueIndex: max(0, positionInQueue),
137
158
  ]
@@ -176,10 +197,17 @@ class MediaSessionManager {
176
197
  commandCenter.togglePlayPauseCommand.removeTarget(nil)
177
198
  commandCenter.nextTrackCommand.removeTarget(nil)
178
199
  commandCenter.previousTrackCommand.removeTarget(nil)
200
+ commandCenter.skipForwardCommand.removeTarget(nil)
201
+ commandCenter.skipBackwardCommand.removeTarget(nil)
179
202
  commandCenter.seekForwardCommand.removeTarget(nil)
180
203
  commandCenter.seekBackwardCommand.removeTarget(nil)
204
+ commandCenter.changePlaybackRateCommand.removeTarget(nil)
181
205
  commandCenter.changePlaybackPositionCommand.removeTarget(nil)
182
206
 
207
+ updateSkipCommandIntervals()
208
+ commandCenter.changePlaybackRateCommand.supportedPlaybackRates =
209
+ Constants.supportedPlaybackRates.map { NSNumber(value: $0) }
210
+
183
211
  // Play
184
212
  commandCenter.playCommand.isEnabled = true
185
213
  commandCenter.playCommand.addTarget { [weak self] _ in
@@ -221,9 +249,45 @@ class MediaSessionManager {
221
249
  return .success
222
250
  }
223
251
 
252
+ commandCenter.skipForwardCommand.isEnabled = false
253
+ commandCenter.skipForwardCommand.addTarget { [weak self] event in
254
+ guard let self = self, let core = self.trackPlayerCore else { return .commandFailed }
255
+ let interval = (event as? MPSkipIntervalCommandEvent)?.interval ?? self.remoteSkipForwardInterval
256
+ Task { await core.seekBy(offset: interval) }
257
+ return .success
258
+ }
259
+
260
+ commandCenter.skipBackwardCommand.isEnabled = false
261
+ commandCenter.skipBackwardCommand.addTarget { [weak self] event in
262
+ guard let self = self, let core = self.trackPlayerCore else { return .commandFailed }
263
+ let interval = (event as? MPSkipIntervalCommandEvent)?.interval ?? self.remoteSkipBackwardInterval
264
+ Task { await core.seekBy(offset: -interval) }
265
+ return .success
266
+ }
267
+
224
268
  commandCenter.seekForwardCommand.isEnabled = false
225
269
  commandCenter.seekBackwardCommand.isEnabled = false
226
270
 
271
+ commandCenter.changePlaybackRateCommand.isEnabled = false
272
+ commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
273
+ guard let core = self?.trackPlayerCore,
274
+ let rateEvent = event as? MPChangePlaybackRateCommandEvent
275
+ else {
276
+ return .commandFailed
277
+ }
278
+ let rate = Double(rateEvent.playbackRate)
279
+ guard rate.isFinite, rate > 0 else { return .commandFailed }
280
+ Task {
281
+ do {
282
+ try await core.setPlaybackSpeed(rate)
283
+ } catch {
284
+ NitroPlayerLogger.log(
285
+ "MediaSessionManager", "❌ Failed to set remote playback rate: \(error)")
286
+ }
287
+ }
288
+ return .success
289
+ }
290
+
227
291
  // Scrubber
228
292
  commandCenter.changePlaybackPositionCommand.isEnabled = false
229
293
  commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
@@ -257,6 +321,9 @@ class MediaSessionManager {
257
321
 
258
322
  commandCenter.nextTrackCommand.isEnabled = hasCurrentTrack && isNotLast
259
323
  commandCenter.previousTrackCommand.isEnabled = hasCurrentTrack
324
+ commandCenter.skipForwardCommand.isEnabled = hasCurrentTrack
325
+ commandCenter.skipBackwardCommand.isEnabled = hasCurrentTrack
326
+ commandCenter.changePlaybackRateCommand.isEnabled = hasCurrentTrack
260
327
  commandCenter.changePlaybackPositionCommand.isEnabled = hasCurrentTrack && hasDuration
261
328
  }
262
329
 
@@ -264,11 +331,29 @@ class MediaSessionManager {
264
331
  let commandCenter = MPRemoteCommandCenter.shared()
265
332
  commandCenter.nextTrackCommand.isEnabled = false
266
333
  commandCenter.previousTrackCommand.isEnabled = false
334
+ commandCenter.skipForwardCommand.isEnabled = false
335
+ commandCenter.skipBackwardCommand.isEnabled = false
336
+ commandCenter.changePlaybackRateCommand.isEnabled = false
267
337
  commandCenter.changePlaybackPositionCommand.isEnabled = false
268
338
  }
269
339
 
270
340
  // MARK: - Helpers
271
341
 
342
+ private func sanitizedPositive(_ value: Double?) -> Double? {
343
+ guard let value = value, value.isFinite, value > 0 else { return nil }
344
+ return value
345
+ }
346
+
347
+ private func updateSkipCommandIntervals() {
348
+ let commandCenter = MPRemoteCommandCenter.shared()
349
+ commandCenter.skipForwardCommand.preferredIntervals = [
350
+ NSNumber(value: remoteSkipForwardInterval)
351
+ ]
352
+ commandCenter.skipBackwardCommand.preferredIntervals = [
353
+ NSNumber(value: remoteSkipBackwardInterval)
354
+ ]
355
+ }
356
+
272
357
  private func clearNowPlayingInfo() {
273
358
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
274
359
  lastArtworkUrl = nil
@@ -14,7 +14,13 @@ class PlaylistManager {
14
14
  private var listeners: [(String, ([PlaylistModel], QueueOperation?) -> Void)] = []
15
15
  private var playlistListeners: [String: [(String, (PlaylistModel, QueueOperation?) -> Void)]] =
16
16
  [:]
17
- private var currentPlaylistId: String?
17
+ // Backing store must only be touched inside `queue`; every current use of the
18
+ // computed property is outside a queue.sync block, so the accessors can sync.
19
+ private var _currentPlaylistId: String?
20
+ private var currentPlaylistId: String? {
21
+ get { queue.sync { _currentPlaylistId } }
22
+ set { queue.sync { _currentPlaylistId = newValue } }
23
+ }
18
24
  private let queue = DispatchQueue(label: "com.margelo.nitro.nitroplayer.playlist")
19
25
  private var saveDebounceWorkItem: DispatchWorkItem?
20
26
 
@@ -45,15 +51,16 @@ class PlaylistManager {
45
51
  * Delete a playlist
46
52
  */
47
53
  func deletePlaylist(playlistId: String) -> Bool {
48
- let removed = queue.sync {
49
- return playlists.removeValue(forKey: playlistId) != nil
54
+ let removed = queue.sync { () -> Bool in
55
+ guard playlists.removeValue(forKey: playlistId) != nil else { return false }
56
+ if _currentPlaylistId == playlistId {
57
+ _currentPlaylistId = nil
58
+ }
59
+ playlistListeners.removeValue(forKey: playlistId)
60
+ return true
50
61
  }
51
62
 
52
63
  if removed {
53
- if currentPlaylistId == playlistId {
54
- currentPlaylistId = nil
55
- }
56
- playlistListeners.removeValue(forKey: playlistId)
57
64
  scheduleSave()
58
65
  notifyPlaylistsChanged(.remove)
59
66
  return true
@@ -68,11 +75,9 @@ class PlaylistManager {
68
75
  func updatePlaylist(
69
76
  playlistId: String, name: String? = nil, description: String? = nil, artwork: String? = nil
70
77
  ) -> Bool {
71
- guard let playlist = queue.sync(execute: { playlists[playlistId] }) else {
72
- return false
73
- }
74
-
75
- queue.sync {
78
+ // Single sync: a separate fetch-then-store loses one of two concurrent edits
79
+ let updated = queue.sync { () -> Bool in
80
+ guard let playlist = playlists[playlistId] else { return false }
76
81
  playlists[playlistId] = PlaylistModel(
77
82
  id: playlist.id,
78
83
  name: name ?? playlist.name,
@@ -80,7 +85,9 @@ class PlaylistManager {
80
85
  artwork: artwork ?? playlist.artwork,
81
86
  tracks: playlist.tracks
82
87
  )
88
+ return true
83
89
  }
90
+ guard updated else { return false }
84
91
 
85
92
  scheduleSave()
86
93
  notifyPlaylistChanged(playlistId, .update)
@@ -111,11 +118,8 @@ class PlaylistManager {
111
118
  * Add a track to a playlist
112
119
  */
113
120
  func addTrackToPlaylist(playlistId: String, track: TrackItem, index: Int? = nil) -> Bool {
114
- guard let playlist = queue.sync(execute: { playlists[playlistId] }) else {
115
- return false
116
- }
117
-
118
- queue.sync {
121
+ let added = queue.sync { () -> Bool in
122
+ guard let playlist = playlists[playlistId] else { return false }
119
123
  var tracks = playlist.tracks
120
124
  if let index = index, index >= 0 && index <= tracks.count {
121
125
  tracks.insert(track, at: index)
@@ -129,7 +133,9 @@ class PlaylistManager {
129
133
  artwork: playlist.artwork,
130
134
  tracks: tracks
131
135
  )
136
+ return true
132
137
  }
138
+ guard added else { return false }
133
139
 
134
140
  scheduleSave()
135
141
  notifyPlaylistChanged(playlistId, .add)
@@ -146,11 +152,8 @@ class PlaylistManager {
146
152
  * Add multiple tracks to a playlist at once
147
153
  */
148
154
  func addTracksToPlaylist(playlistId: String, tracks: [TrackItem], index: Int? = nil) -> Bool {
149
- guard let playlist = queue.sync(execute: { playlists[playlistId] }) else {
150
- return false
151
- }
152
-
153
- queue.sync {
155
+ let added = queue.sync { () -> Bool in
156
+ guard let playlist = playlists[playlistId] else { return false }
154
157
  var currentTracks = playlist.tracks
155
158
  if let index = index, index >= 0 && index <= currentTracks.count {
156
159
  currentTracks.insert(contentsOf: tracks, at: index)
@@ -164,7 +167,9 @@ class PlaylistManager {
164
167
  artwork: playlist.artwork,
165
168
  tracks: currentTracks
166
169
  )
170
+ return true
167
171
  }
172
+ guard added else { return false }
168
173
 
169
174
  scheduleSave()
170
175
  notifyPlaylistChanged(playlistId, .add)
@@ -182,11 +187,8 @@ class PlaylistManager {
182
187
  * Remove a track from a playlist
183
188
  */
184
189
  func removeTrackFromPlaylist(playlistId: String, trackId: String) -> Bool {
185
- guard let playlist = queue.sync(execute: { playlists[playlistId] }) else {
186
- return false
187
- }
188
-
189
- let removed = queue.sync {
190
+ let removed = queue.sync { () -> Bool in
191
+ guard let playlist = playlists[playlistId] else { return false }
190
192
  var tracks = playlist.tracks
191
193
  let initialCount = tracks.count
192
194
  tracks.removeAll { $0.id == trackId }
@@ -222,30 +224,27 @@ class PlaylistManager {
222
224
  * Reorder a track in a playlist
223
225
  */
224
226
  func reorderTrackInPlaylist(playlistId: String, trackId: String, newIndex: Int) -> Bool {
225
- guard let playlist = queue.sync(execute: { playlists[playlistId] }) else {
226
- return false
227
- }
228
-
229
- let tracks = playlist.tracks
230
- guard let oldIndex = tracks.firstIndex(where: { $0.id == trackId }),
231
- newIndex >= 0 && newIndex < tracks.count
232
- else {
233
- return false
234
- }
235
-
236
- queue.sync {
237
- var reorderedTracks = tracks
238
- let track = reorderedTracks.remove(at: oldIndex)
239
- reorderedTracks.insert(track, at: newIndex)
227
+ let reordered = queue.sync { () -> Bool in
228
+ guard let playlist = playlists[playlistId] else { return false }
229
+ var tracks = playlist.tracks
230
+ guard let oldIndex = tracks.firstIndex(where: { $0.id == trackId }),
231
+ newIndex >= 0 && newIndex < tracks.count
232
+ else {
233
+ return false
234
+ }
235
+ let track = tracks.remove(at: oldIndex)
236
+ tracks.insert(track, at: newIndex)
240
237
 
241
238
  playlists[playlistId] = PlaylistModel(
242
239
  id: playlist.id,
243
240
  name: playlist.name,
244
241
  description: playlist.description,
245
242
  artwork: playlist.artwork,
246
- tracks: reorderedTracks
243
+ tracks: tracks
247
244
  )
245
+ return true
248
246
  }
247
+ guard reordered else { return false }
249
248
 
250
249
  scheduleSave()
251
250
  notifyPlaylistChanged(playlistId, .update)
@@ -417,9 +416,11 @@ class PlaylistManager {
417
416
  }
418
417
 
419
418
  private func scheduleSave() {
420
- saveDebounceWorkItem?.cancel()
421
419
  let work = DispatchWorkItem { [weak self] in self?.saveToFile() }
422
- saveDebounceWorkItem = work
420
+ queue.sync {
421
+ saveDebounceWorkItem?.cancel()
422
+ saveDebounceWorkItem = work
423
+ }
423
424
  // Use global background queue — saveToFile calls queue.sync internally,
424
425
  // which would deadlock if scheduled on queue itself.
425
426
  DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3, execute: work)
@@ -459,7 +460,7 @@ class PlaylistManager {
459
460
  }
460
461
  let wrapper: [String: Any] = [
461
462
  "playlists": playlistsData,
462
- "currentPlaylistId": currentPlaylistId as Any,
463
+ "currentPlaylistId": queue.sync { _currentPlaylistId } as Any,
463
464
  ]
464
465
  let data = try JSONSerialization.data(withJSONObject: wrapper, options: [])
465
466
  try NitroPlayerStorage.write(filename: "playlists.json", data: data)
@@ -22,13 +22,20 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
22
22
  }
23
23
 
24
24
  func deletePlaylist(playlistId: String) throws -> Promise<Void> {
25
- Promise.async { _ = self.playlistManager.deletePlaylist(playlistId: playlistId) }
25
+ Promise.async {
26
+ guard self.playlistManager.deletePlaylist(playlistId: playlistId) else {
27
+ throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
28
+ }
29
+ }
26
30
  }
27
31
 
28
32
  func updatePlaylist(playlistId: String, name: String?, description: String?, artwork: String?) throws -> Promise<Void> {
29
33
  Promise.async {
30
- _ = self.playlistManager.updatePlaylist(
34
+ guard self.playlistManager.updatePlaylist(
31
35
  playlistId: playlistId, name: name, description: description, artwork: artwork)
36
+ else {
37
+ throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
38
+ }
32
39
  self.core.updatePlaylist(playlistId: playlistId)
33
40
  }
34
41
  }
@@ -48,29 +55,41 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
48
55
 
49
56
  func addTrackToPlaylist(playlistId: String, track: TrackItem, index: Double?) throws -> Promise<Void> {
50
57
  Promise.async {
51
- _ = self.playlistManager.addTrackToPlaylist(playlistId: playlistId, track: track, index: index.map { Int($0) })
58
+ guard self.playlistManager.addTrackToPlaylist(playlistId: playlistId, track: track, index: index.map { Int($0) })
59
+ else {
60
+ throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
61
+ }
52
62
  self.core.updatePlaylist(playlistId: playlistId)
53
63
  }
54
64
  }
55
65
 
56
66
  func addTracksToPlaylist(playlistId: String, tracks: [TrackItem], index: Double?) throws -> Promise<Void> {
57
67
  Promise.async {
58
- _ = self.playlistManager.addTracksToPlaylist(playlistId: playlistId, tracks: tracks, index: index.map { Int($0) })
68
+ guard self.playlistManager.addTracksToPlaylist(playlistId: playlistId, tracks: tracks, index: index.map { Int($0) })
69
+ else {
70
+ throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
71
+ }
59
72
  self.core.updatePlaylist(playlistId: playlistId)
60
73
  }
61
74
  }
62
75
 
63
76
  func removeTrackFromPlaylist(playlistId: String, trackId: String) throws -> Promise<Void> {
64
77
  Promise.async {
65
- _ = self.playlistManager.removeTrackFromPlaylist(playlistId: playlistId, trackId: trackId)
78
+ guard self.playlistManager.removeTrackFromPlaylist(playlistId: playlistId, trackId: trackId)
79
+ else {
80
+ throw RuntimeError.error(withMessage: "Track \(trackId) not found in playlist \(playlistId)")
81
+ }
66
82
  self.core.updatePlaylist(playlistId: playlistId)
67
83
  }
68
84
  }
69
85
 
70
86
  func reorderTrackInPlaylist(playlistId: String, trackId: String, newIndex: Double) throws -> Promise<Void> {
71
87
  Promise.async {
72
- _ = self.playlistManager.reorderTrackInPlaylist(
88
+ guard self.playlistManager.reorderTrackInPlaylist(
73
89
  playlistId: playlistId, trackId: trackId, newIndex: Int(newIndex))
90
+ else {
91
+ throw RuntimeError.error(withMessage: "Invalid reorder for track \(trackId) in playlist \(playlistId)")
92
+ }
74
93
  self.core.updatePlaylist(playlistId: playlistId)
75
94
  }
76
95
  }
@@ -86,8 +105,10 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
86
105
  // Update PlaylistManager.currentPlaylistId so getCurrentPlaylistId() returns correctly
87
106
  if self.playlistManager.loadPlaylist(playlistId: playlistId, index: startIndex) {
88
107
  self.core.loadPlaylistOnQueue(playlistId: playlistId, startIndex: startIndex)
108
+ promise.resolve(withResult: ())
109
+ } else {
110
+ promise.reject(withError: RuntimeError.error(withMessage: "Invalid playlist or index: \(playlistId)"))
89
111
  }
90
- promise.resolve(withResult: ())
91
112
  }
92
113
  return promise
93
114
  }
@@ -5,6 +5,7 @@ type PlaybackProgressCallback = (position: number, totalDuration: number, isManu
5
5
  type SeekCallback = (position: number, totalDuration: number) => void;
6
6
  type TimedMetadataCallback = (metadata: TimedMetadata) => void;
7
7
  type TemporaryQueueCallback = (playNextQueue: TrackItem[], upNextQueue: TrackItem[]) => void;
8
+ type AndroidAutoConnectionCallback = (connected: boolean) => void;
8
9
  /**
9
10
  * Internal subscription manager that allows multiple hooks to subscribe
10
11
  * to a single native callback. This solves the problem where registering
@@ -18,7 +19,9 @@ declare class CallbackSubscriptionManager {
18
19
  private timedMetadataSubscribers;
19
20
  private temporaryQueueSubscribers;
20
21
  private playlistsChangedSubscribers;
22
+ private androidAutoConnectionSubscribers;
21
23
  private isPlaylistsChangedRegistered;
24
+ private isAndroidAutoConnectionRegistered;
22
25
  private isPlaybackStateRegistered;
23
26
  private isTrackChangeRegistered;
24
27
  private isPlaybackProgressRegistered;
@@ -61,6 +64,12 @@ declare class CallbackSubscriptionManager {
61
64
  private ensurePlaylistsChangedRegistered;
62
65
  private ensureTemporaryQueueRegistered;
63
66
  private ensureSeekRegistered;
67
+ /**
68
+ * Subscribe to Android Auto connection changes
69
+ * @returns Unsubscribe function
70
+ */
71
+ subscribeToAndroidAutoConnection(callback: AndroidAutoConnectionCallback): () => void;
72
+ private ensureAndroidAutoConnectionRegistered;
64
73
  /**
65
74
  * Subscribe to in-stream (timed) metadata events
66
75
  * @returns Unsubscribe function
@@ -12,7 +12,9 @@ class CallbackSubscriptionManager {
12
12
  timedMetadataSubscribers = new Set();
13
13
  temporaryQueueSubscribers = new Set();
14
14
  playlistsChangedSubscribers = new Set();
15
+ androidAutoConnectionSubscribers = new Set();
15
16
  isPlaylistsChangedRegistered = false;
17
+ isAndroidAutoConnectionRegistered = false;
16
18
  isPlaybackStateRegistered = false;
17
19
  isTrackChangeRegistered = false;
18
20
  isPlaybackProgressRegistered = false;
@@ -205,6 +207,37 @@ class CallbackSubscriptionManager {
205
207
  console.error('[CallbackManager] Failed to register seek callback:', error);
206
208
  }
207
209
  }
210
+ /**
211
+ * Subscribe to Android Auto connection changes
212
+ * @returns Unsubscribe function
213
+ */
214
+ subscribeToAndroidAutoConnection(callback) {
215
+ this.androidAutoConnectionSubscribers.add(callback);
216
+ this.ensureAndroidAutoConnectionRegistered();
217
+ return () => {
218
+ this.androidAutoConnectionSubscribers.delete(callback);
219
+ };
220
+ }
221
+ ensureAndroidAutoConnectionRegistered() {
222
+ if (this.isAndroidAutoConnectionRegistered)
223
+ return;
224
+ try {
225
+ TrackPlayer.onAndroidAutoConnectionChange((connected) => {
226
+ this.androidAutoConnectionSubscribers.forEach((subscriber) => {
227
+ try {
228
+ subscriber(connected);
229
+ }
230
+ catch (error) {
231
+ console.error('[CallbackManager] Error in Android Auto connection subscriber:', error);
232
+ }
233
+ });
234
+ });
235
+ this.isAndroidAutoConnectionRegistered = true;
236
+ }
237
+ catch (error) {
238
+ console.error('[CallbackManager] Failed to register Android Auto connection callback:', error);
239
+ }
240
+ }
208
241
  /**
209
242
  * Subscribe to in-stream (timed) metadata events
210
243
  * @returns Unsubscribe function
@@ -61,6 +61,18 @@ export function useActualQueue() {
61
61
  }
62
62
  }
63
63
  }, []);
64
+ // Skips involving temp tracks fire track-change and temporary-queue-change
65
+ // together; the gate collapses the burst into one getActualQueue() fetch
66
+ const fetchScheduled = useRef(false);
67
+ const scheduleUpdate = useCallback(() => {
68
+ if (fetchScheduled.current)
69
+ return;
70
+ fetchScheduled.current = true;
71
+ queueMicrotask(() => {
72
+ fetchScheduled.current = false;
73
+ updateQueue();
74
+ });
75
+ }, [updateQueue]);
64
76
  const refreshQueue = useCallback(() => {
65
77
  if (!isMounted.current)
66
78
  return;
@@ -83,22 +95,22 @@ export function useActualQueue() {
83
95
  // track changes and temporary-queue edits do.
84
96
  useEffect(() => {
85
97
  return callbackManager.subscribeToTrackChange(() => {
86
- updateQueue();
98
+ scheduleUpdate();
87
99
  });
88
- }, [updateQueue]);
100
+ }, [scheduleUpdate]);
89
101
  // Update queue when playNext / upNext change (native pushes the new lists).
90
102
  useEffect(() => {
91
103
  return callbackManager.subscribeToTemporaryQueueChange(() => {
92
- updateQueue();
104
+ scheduleUpdate();
93
105
  });
94
- }, [updateQueue]);
106
+ }, [scheduleUpdate]);
95
107
  // Update queue when the playlist itself is edited (track added / removed / reordered).
96
108
  // Without this, a playlist mutation while paused would leave the queue stale until the
97
109
  // next track change — playback-state changes used to paper over it.
98
110
  useEffect(() => {
99
111
  return callbackManager.subscribeToPlaylistsChanged(() => {
100
- updateQueue();
112
+ scheduleUpdate();
101
113
  });
102
- }, [updateQueue]);
114
+ }, [scheduleUpdate]);
103
115
  return { queue, refreshQueue, isLoading };
104
116
  }