react-native-nitro-player 0.6.1 → 0.7.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +9 -13
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +45 -90
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +48 -182
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +21 -77
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +55 -104
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +113 -123
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +82 -0
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ListenerRegistry.kt +48 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +62 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +153 -1887
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +122 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +44 -0
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +162 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +165 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +161 -0
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +28 -0
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +121 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +98 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +27 -18
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +11 -58
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +13 -30
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +102 -162
  23. package/ios/HybridDownloadManager.swift +32 -26
  24. package/ios/HybridEqualizer.swift +48 -35
  25. package/ios/HybridTrackPlayer.swift +127 -102
  26. package/ios/core/ListenerRegistry.swift +60 -0
  27. package/ios/core/TrackPlayerCore.swift +130 -2356
  28. package/ios/core/TrackPlayerListener.swift +395 -0
  29. package/ios/core/TrackPlayerNotify.swift +52 -0
  30. package/ios/core/TrackPlayerPlayback.swift +274 -0
  31. package/ios/core/TrackPlayerQueue.swift +212 -0
  32. package/ios/core/TrackPlayerQueueBuild.swift +482 -0
  33. package/ios/core/TrackPlayerTempQueue.swift +167 -0
  34. package/ios/core/TrackPlayerUrlLoader.swift +169 -0
  35. package/ios/equalizer/EqualizerCore.swift +24 -89
  36. package/ios/media/MediaSessionManager.swift +32 -49
  37. package/ios/playlist/PlaylistManager.swift +2 -9
  38. package/ios/queue/HybridPlayerQueue.swift +69 -66
  39. package/lib/hooks/useDownloadedTracks.js +16 -13
  40. package/lib/hooks/useEqualizer.d.ts +4 -4
  41. package/lib/hooks/useEqualizer.js +12 -12
  42. package/lib/hooks/useEqualizerPresets.d.ts +3 -3
  43. package/lib/hooks/useEqualizerPresets.js +12 -18
  44. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +2 -2
  45. package/lib/specs/AudioDevices.nitro.d.ts +2 -2
  46. package/lib/specs/DownloadManager.nitro.d.ts +10 -10
  47. package/lib/specs/Equalizer.nitro.d.ts +9 -9
  48. package/lib/specs/TrackPlayer.nitro.d.ts +38 -16
  49. package/nitro.json +44 -11
  50. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +63 -24
  51. package/nitrogen/generated/android/c++/JCurrentPlayingType.hpp +1 -1
  52. package/nitrogen/generated/android/c++/JDownloadConfig.hpp +1 -1
  53. package/nitrogen/generated/android/c++/JDownloadError.hpp +1 -1
  54. package/nitrogen/generated/android/c++/JDownloadErrorReason.hpp +1 -1
  55. package/nitrogen/generated/android/c++/JDownloadProgress.hpp +1 -1
  56. package/nitrogen/generated/android/c++/JDownloadQueueStatus.hpp +1 -1
  57. package/nitrogen/generated/android/c++/JDownloadState.hpp +1 -1
  58. package/nitrogen/generated/android/c++/JDownloadStorageInfo.hpp +1 -1
  59. package/nitrogen/generated/android/c++/JDownloadTask.hpp +1 -1
  60. package/nitrogen/generated/android/c++/JDownloadedPlaylist.hpp +1 -1
  61. package/nitrogen/generated/android/c++/JDownloadedTrack.hpp +1 -1
  62. package/nitrogen/generated/android/c++/JEqualizerBand.hpp +1 -1
  63. package/nitrogen/generated/android/c++/JEqualizerPreset.hpp +1 -1
  64. package/nitrogen/generated/android/c++/JEqualizerState.hpp +1 -1
  65. package/nitrogen/generated/android/c++/JFunc_void_DownloadProgress.hpp +2 -2
  66. package/nitrogen/generated/android/c++/JFunc_void_DownloadedTrack.hpp +2 -2
  67. package/nitrogen/generated/android/c++/JFunc_void_TrackItem_std__optional_Reason_.hpp +2 -2
  68. package/nitrogen/generated/android/c++/JFunc_void_TrackPlayerState_std__optional_Reason_.hpp +2 -2
  69. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +2 -2
  70. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +2 -2
  71. package/nitrogen/generated/android/c++/JFunc_void_double_double_std__optional_bool_.hpp +2 -2
  72. package/nitrogen/generated/android/c++/JFunc_void_std__optional_std__variant_nitro__NullType__std__string__.hpp +2 -2
  73. package/nitrogen/generated/android/c++/JFunc_void_std__string_Playlist_std__optional_QueueOperation_.hpp +2 -2
  74. package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string_DownloadState_std__optional_DownloadError_.hpp +2 -2
  75. package/nitrogen/generated/android/c++/JFunc_void_std__vector_EqualizerBand_.hpp +2 -2
  76. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Playlist__std__optional_QueueOperation_.hpp +2 -2
  77. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double.hpp +2 -2
  78. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__std__vector_TrackItem_.hpp +122 -0
  79. package/nitrogen/generated/android/c++/JGainRange.hpp +1 -1
  80. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +49 -30
  81. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +21 -24
  82. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.cpp +35 -28
  83. package/nitrogen/generated/android/c++/JHybridAudioDevicesSpec.hpp +20 -23
  84. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.cpp +197 -93
  85. package/nitrogen/generated/android/c++/JHybridDownloadManagerSpec.hpp +29 -32
  86. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +157 -67
  87. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +28 -31
  88. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +138 -53
  89. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +27 -30
  90. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +282 -69
  91. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +35 -30
  92. package/nitrogen/generated/android/c++/JPlaybackSource.hpp +1 -1
  93. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +1 -1
  94. package/nitrogen/generated/android/c++/JPlayerState.hpp +1 -1
  95. package/nitrogen/generated/android/c++/JPlaylist.hpp +1 -1
  96. package/nitrogen/generated/android/c++/JPresetType.hpp +1 -1
  97. package/nitrogen/generated/android/c++/JQueueOperation.hpp +1 -1
  98. package/nitrogen/generated/android/c++/JReason.hpp +1 -1
  99. package/nitrogen/generated/android/c++/JRepeatMode.hpp +1 -1
  100. package/nitrogen/generated/android/c++/JStorageLocation.hpp +1 -1
  101. package/nitrogen/generated/android/c++/JTAudioDevice.hpp +1 -1
  102. package/nitrogen/generated/android/c++/JTrackItem.hpp +1 -1
  103. package/nitrogen/generated/android/c++/JTrackPlayerState.hpp +1 -1
  104. package/nitrogen/generated/android/c++/JVariant_NullType_Double.hpp +3 -3
  105. package/nitrogen/generated/android/c++/JVariant_NullType_DownloadError.hpp +3 -3
  106. package/nitrogen/generated/android/c++/JVariant_NullType_DownloadTask.hpp +3 -3
  107. package/nitrogen/generated/android/c++/JVariant_NullType_DownloadedPlaylist.hpp +3 -3
  108. package/nitrogen/generated/android/c++/JVariant_NullType_DownloadedTrack.hpp +3 -3
  109. package/nitrogen/generated/android/c++/JVariant_NullType_Playlist.hpp +3 -3
  110. package/nitrogen/generated/android/c++/JVariant_NullType_String.hpp +3 -3
  111. package/nitrogen/generated/android/c++/JVariant_NullType_TrackItem.hpp +3 -3
  112. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__vector_TrackItem__std__vector_TrackItem_.kt +80 -0
  113. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +18 -20
  114. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAudioDevicesSpec.kt +17 -19
  115. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridDownloadManagerSpec.kt +25 -28
  116. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridEqualizerSpec.kt +25 -27
  117. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +24 -26
  118. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +60 -26
  119. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_Double.kt +0 -6
  120. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_DownloadError.kt +0 -6
  121. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_DownloadTask.kt +0 -6
  122. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_DownloadedPlaylist.kt +0 -6
  123. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_DownloadedTrack.kt +0 -6
  124. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_Playlist.kt +0 -6
  125. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_String.kt +0 -6
  126. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Variant_NullType_TrackItem.kt +0 -6
  127. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +74 -18
  128. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +380 -151
  129. package/nitrogen/generated/ios/c++/HybridDownloadManagerSpecSwift.hpp +10 -10
  130. package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +12 -9
  131. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +23 -8
  132. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +82 -8
  133. package/nitrogen/generated/ios/swift/Func_void_EqualizerState.swift +46 -0
  134. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedPlaylist_.swift +58 -0
  135. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__DownloadedTrack_.swift +58 -0
  136. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__std__string_.swift +58 -0
  137. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedPlaylist_.swift +46 -0
  138. package/nitrogen/generated/ios/swift/Func_void_std__vector_DownloadedTrack_.swift +46 -0
  139. package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +5 -5
  140. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__std__vector_TrackItem_.swift +46 -0
  141. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec.swift +10 -10
  142. package/nitrogen/generated/ios/swift/HybridDownloadManagerSpec_cxx.swift +141 -71
  143. package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +9 -9
  144. package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +105 -41
  145. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +8 -8
  146. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +95 -32
  147. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +16 -8
  148. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +267 -32
  149. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +3 -2
  150. package/nitrogen/generated/shared/c++/HybridAudioDevicesSpec.hpp +2 -1
  151. package/nitrogen/generated/shared/c++/HybridDownloadManagerSpec.hpp +10 -10
  152. package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +10 -9
  153. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +9 -8
  154. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +8 -0
  155. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +16 -8
  156. package/package.json +3 -3
  157. package/src/hooks/useDownloadedTracks.ts +17 -13
  158. package/src/hooks/useEqualizer.ts +16 -16
  159. package/src/hooks/useEqualizerPresets.ts +15 -21
  160. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +2 -2
  161. package/src/specs/AudioDevices.nitro.ts +2 -2
  162. package/src/specs/DownloadManager.nitro.ts +10 -10
  163. package/src/specs/Equalizer.nitro.ts +9 -9
  164. package/src/specs/TrackPlayer.nitro.ts +52 -16
@@ -0,0 +1,169 @@
1
+ //
2
+ // TrackPlayerUrlLoader.swift
3
+ // NitroPlayer
4
+ //
5
+ // Created by Ritesh Shukla on 25/03/26.
6
+ //
7
+
8
+ import AVFoundation
9
+ import Foundation
10
+
11
+ extension TrackPlayerCore {
12
+
13
+ func updateTracks(tracks: [TrackItem]) async {
14
+ await withPlayerQueueNoThrow { self.updateTracksInternal(tracks: tracks) }
15
+ }
16
+
17
+ func getTracksById(trackIds: [String]) async -> [TrackItem] {
18
+ await withPlayerQueueNoThrow { self.playlistManager.getTracksById(trackIds: trackIds) }
19
+ }
20
+
21
+ func getTracksNeedingUrls() async -> [TrackItem] {
22
+ await withPlayerQueueNoThrow { self.getTracksNeedingUrlsInternal() }
23
+ }
24
+
25
+ func getNextTracks(count: Int) async -> [TrackItem] {
26
+ await withPlayerQueueNoThrow { self.getNextTracksInternal(count: count) }
27
+ }
28
+
29
+ // MARK: - Internal
30
+
31
+ func updateTracksInternal(tracks: [TrackItem]) {
32
+ NitroPlayerLogger.log("TrackPlayerCore", "🔄 updateTracks: \(tracks.count) updates")
33
+
34
+ let currentTrack = self.getCurrentTrack()
35
+ let currentTrackId = currentTrack?.id
36
+ // A track is only "empty" if it has no remote URL AND is not downloaded.
37
+ let currentTrackIsEmpty = currentTrack.map {
38
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
39
+ } ?? false
40
+
41
+ let safeTracks = tracks.filter { track in
42
+ switch true {
43
+ case track.id == currentTrackId && !currentTrackIsEmpty:
44
+ NitroPlayerLogger.log("TrackPlayerCore",
45
+ "⚠️ Skipping update for currently playing track: \(track.id) (preserves gapless)")
46
+ return false
47
+ case track.id == currentTrackId && currentTrackIsEmpty:
48
+ NitroPlayerLogger.log("TrackPlayerCore",
49
+ "🔄 Updating current track with no URL: \(track.id)")
50
+ return !track.url.isEmpty
51
+ case track.url.isEmpty:
52
+ NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Skipping track with empty URL: \(track.id)")
53
+ return false
54
+ default:
55
+ return true
56
+ }
57
+ }
58
+
59
+ guard !safeTracks.isEmpty else {
60
+ NitroPlayerLogger.log("TrackPlayerCore", "✅ No valid updates to apply")
61
+ return
62
+ }
63
+
64
+ // Invalidate preloaded assets for tracks with updated data
65
+ let updatedTrackIds = Set(safeTracks.map { $0.id })
66
+ for trackId in updatedTrackIds {
67
+ if self.preloadedAssets[trackId] != nil {
68
+ NitroPlayerLogger.log("TrackPlayerCore", "🗑️ Invalidating preloaded asset for track: \(trackId)")
69
+ self.preloadedAssets.removeValue(forKey: trackId)
70
+ }
71
+ }
72
+
73
+ // Update in PlaylistManager
74
+ let affectedPlaylists = self.playlistManager.updateTracks(tracks: safeTracks)
75
+
76
+ // If the current track had no URL and now has one, replace the current AVPlayerItem
77
+ if let update = currentTrack, currentTrackIsEmpty, !update.url.isEmpty {
78
+ NitroPlayerLogger.log("TrackPlayerCore",
79
+ "🔄 Replacing current AVPlayerItem for track with resolved URL: \(update.id)")
80
+ if let newItem = self.createGaplessPlayerItem(for: update, isPreload: false) {
81
+ self.player?.replaceCurrentItem(with: newItem)
82
+ }
83
+ }
84
+
85
+ // Rebuild queue if current playlist was affected
86
+ if let currentId = self.currentPlaylistId,
87
+ let updateCount = affectedPlaylists[currentId]
88
+ {
89
+ NitroPlayerLogger.log("TrackPlayerCore",
90
+ "🔄 Rebuilding queue - \(updateCount) tracks updated in current playlist")
91
+
92
+ // Sync currentTracks from the freshly-updated PlaylistManager
93
+ if let updatedPlaylist = self.playlistManager.getPlaylist(playlistId: currentId) {
94
+ self.currentTracks = updatedPlaylist.tracks
95
+ NitroPlayerLogger.log("TrackPlayerCore",
96
+ "📥 Synced currentTracks from PlaylistManager (\(self.currentTracks.count) tracks)")
97
+ }
98
+
99
+ if self.player?.currentItem == nil, let player = self.player {
100
+ // No AVPlayerItem exists yet — lazy-load mode: URLs were empty when the queue first loaded.
101
+ NitroPlayerLogger.log("TrackPlayerCore",
102
+ "🔄 No current item — full queue rebuild from currentTrackIndex \(self.currentTrackIndex)")
103
+ player.removeAllItems()
104
+ var lastItem: AVPlayerItem? = nil
105
+ for (offset, track) in self.currentTracks[max(0, self.currentTrackIndex)...].enumerated() {
106
+ let isPreload = offset < Constants.gaplessPreloadCount
107
+ if let newItem = self.createGaplessPlayerItem(for: track, isPreload: isPreload) {
108
+ player.insert(newItem, after: lastItem)
109
+ lastItem = newItem
110
+ }
111
+ }
112
+ player.play()
113
+ self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
114
+ } else {
115
+ // A current AVPlayerItem already exists — preserve it and only rebuild upcoming items.
116
+ self.rebuildAVQueueFromCurrentPosition(changedTrackIds: updatedTrackIds)
117
+ self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
118
+ }
119
+
120
+ NitroPlayerLogger.log("TrackPlayerCore", "✅ Queue rebuilt, gapless playback preserved")
121
+ }
122
+
123
+ NitroPlayerLogger.log("TrackPlayerCore",
124
+ "✅ Track updates complete - \(affectedPlaylists.count) playlists affected")
125
+ }
126
+
127
+ func getTracksNeedingUrlsInternal() -> [TrackItem] {
128
+ guard let currentId = currentPlaylistId,
129
+ let playlist = playlistManager.getPlaylist(playlistId: currentId)
130
+ else { return [] }
131
+
132
+ // Only return tracks that truly can't play: empty remote URL AND not downloaded locally.
133
+ return playlist.tracks.filter {
134
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
135
+ }
136
+ }
137
+
138
+ func getNextTracksInternal(count: Int) -> [TrackItem] {
139
+ let actualQueue = getActualQueueInternal()
140
+ guard !actualQueue.isEmpty else { return [] }
141
+
142
+ guard let currentTrack = getCurrentTrack(),
143
+ let currentIndex = actualQueue.firstIndex(where: { $0.id == currentTrack.id })
144
+ else { return [] }
145
+
146
+ let startIndex = currentIndex + 1
147
+ let endIndex = min(startIndex + count, actualQueue.count)
148
+ return startIndex < actualQueue.count ? Array(actualQueue[startIndex..<endIndex]) : []
149
+ }
150
+
151
+ func checkUpcomingTracksForUrls(lookahead: Int = 5) {
152
+ let upcomingTracks = getNextTracksInternal(count: lookahead)
153
+
154
+ let currentTrack = getCurrentTrack()
155
+ let currentNeedsUrl = currentTrack.map {
156
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
157
+ } ?? false
158
+ let candidateTracks = currentNeedsUrl ? [currentTrack!] + upcomingTracks : upcomingTracks
159
+
160
+ let tracksNeedingUrls = candidateTracks.filter {
161
+ $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
162
+ }
163
+
164
+ if !tracksNeedingUrls.isEmpty {
165
+ NitroPlayerLogger.log("TrackPlayerCore", "⚠️ \(tracksNeedingUrls.count) upcoming tracks need URLs")
166
+ notifyTracksNeedUpdate(tracks: tracksNeedingUrls, lookahead: lookahead)
167
+ }
168
+ }
169
+ }
@@ -40,27 +40,10 @@ class EqualizerCore {
40
40
  private let currentPresetKey = "eq_current_preset"
41
41
  private let customPresetsKey = "eq_custom_presets"
42
42
 
43
- // MARK: - Weak Callback Wrapper
44
-
45
- private class WeakCallbackBox<T> {
46
- private(set) weak var owner: AnyObject?
47
- let callback: T
48
-
49
- init(owner: AnyObject, callback: T) {
50
- self.owner = owner
51
- self.callback = callback
52
- }
53
-
54
- var isAlive: Bool { owner != nil }
55
- }
56
-
57
- // Event callbacks
58
- private var onEnabledChangeListeners: [WeakCallbackBox<(Bool) -> Void>] = []
59
- private var onBandChangeListeners: [WeakCallbackBox<([EqualizerBand]) -> Void>] = []
60
- private var onPresetChangeListeners: [WeakCallbackBox<(Variant_NullType_String?) -> Void>] = []
61
-
62
- private let listenersQueue = DispatchQueue(
63
- label: "com.equalizer.listeners", attributes: .concurrent)
43
+ // Event listeners (v2 ListenerRegistry with stable IDs)
44
+ private let onEnabledChangeListeners = ListenerRegistry<(Bool) -> Void>()
45
+ private let onBandChangeListeners = ListenerRegistry<([EqualizerBand]) -> Void>()
46
+ private let onPresetChangeListeners = ListenerRegistry<(Variant_NullType_String?) -> Void>()
64
47
 
65
48
  // MARK: - Built-in Presets
66
49
 
@@ -428,88 +411,40 @@ class EqualizerCore {
428
411
  NitroPlayerLogger.log("EqualizerCore", "✅ Restored settings - enabled: \(enabled), gains: \(currentGains)")
429
412
  }
430
413
 
431
- // MARK: - Callback Management
414
+ // MARK: - Listener Management (v2 — stable IDs)
432
415
 
433
- func addOnEnabledChangeListener(owner: AnyObject, _ callback: @escaping (Bool) -> Void) {
434
- listenersQueue.async(flags: .barrier) { [weak self] in
435
- let box = WeakCallbackBox(owner: owner, callback: callback)
436
- self?.onEnabledChangeListeners.append(box)
437
- }
416
+ @discardableResult func addOnEnabledChangeListener(_ callback: @escaping (Bool) -> Void) -> Int64 {
417
+ onEnabledChangeListeners.add(callback)
418
+ }
419
+ @discardableResult func removeOnEnabledChangeListener(id: Int64) -> Bool {
420
+ onEnabledChangeListeners.remove(id: id)
438
421
  }
439
422
 
440
- func addOnBandChangeListener(owner: AnyObject, _ callback: @escaping ([EqualizerBand]) -> Void) {
441
- listenersQueue.async(flags: .barrier) { [weak self] in
442
- let box = WeakCallbackBox(owner: owner, callback: callback)
443
- self?.onBandChangeListeners.append(box)
444
- }
423
+ @discardableResult func addOnBandChangeListener(_ callback: @escaping ([EqualizerBand]) -> Void) -> Int64 {
424
+ onBandChangeListeners.add(callback)
425
+ }
426
+ @discardableResult func removeOnBandChangeListener(id: Int64) -> Bool {
427
+ onBandChangeListeners.remove(id: id)
445
428
  }
446
429
 
447
- func addOnPresetChangeListener(
448
- owner: AnyObject, _ callback: @escaping (Variant_NullType_String?) -> Void
449
- ) {
450
- listenersQueue.async(flags: .barrier) { [weak self] in
451
- let box = WeakCallbackBox(owner: owner, callback: callback)
452
- self?.onPresetChangeListeners.append(box)
453
- }
430
+ @discardableResult func addOnPresetChangeListener(_ callback: @escaping (Variant_NullType_String?) -> Void) -> Int64 {
431
+ onPresetChangeListeners.add(callback)
432
+ }
433
+ @discardableResult func removeOnPresetChangeListener(id: Int64) -> Bool {
434
+ onPresetChangeListeners.remove(id: id)
454
435
  }
455
436
 
456
437
  private func notifyEnabledChange(_ enabled: Bool) {
457
- listenersQueue.async(flags: .barrier) { [weak self] in
458
- guard let self = self else { return }
459
- self.onEnabledChangeListeners.removeAll { !$0.isAlive }
460
-
461
- let callbacks = self.onEnabledChangeListeners.compactMap {
462
- $0.isAlive ? $0.callback : nil
463
- }
464
-
465
- if !callbacks.isEmpty {
466
- DispatchQueue.main.async {
467
- for callback in callbacks {
468
- callback(enabled)
469
- }
470
- }
471
- }
472
- }
438
+ onEnabledChangeListeners.forEach { $0(enabled) }
473
439
  }
474
440
 
475
441
  private func notifyBandChange(_ bands: [EqualizerBand]) {
476
- listenersQueue.async(flags: .barrier) { [weak self] in
477
- guard let self = self else { return }
478
- self.onBandChangeListeners.removeAll { !$0.isAlive }
479
-
480
- let callbacks = self.onBandChangeListeners.compactMap {
481
- $0.isAlive ? $0.callback : nil
482
- }
483
-
484
- if !callbacks.isEmpty {
485
- DispatchQueue.main.async {
486
- for callback in callbacks {
487
- callback(bands)
488
- }
489
- }
490
- }
491
- }
442
+ onBandChangeListeners.forEach { $0(bands) }
492
443
  }
493
444
 
494
445
  private func notifyPresetChange(_ presetName: String?) {
495
- listenersQueue.async(flags: .barrier) { [weak self] in
496
- guard let self = self else { return }
497
- self.onPresetChangeListeners.removeAll { !$0.isAlive }
498
-
499
- let callbacks = self.onPresetChangeListeners.compactMap {
500
- $0.isAlive ? $0.callback : nil
501
- }
502
-
503
- if !callbacks.isEmpty {
504
- let variant: Variant_NullType_String? = presetName.map { .second($0) }
505
-
506
- DispatchQueue.main.async {
507
- for callback in callbacks {
508
- callback(variant)
509
- }
510
- }
511
- }
512
- }
446
+ let variant: Variant_NullType_String? = presetName.map { .second($0) }
447
+ onPresetChangeListeners.forEach { $0(variant) }
513
448
  }
514
449
  }
515
450
 
@@ -20,7 +20,7 @@ class MediaSessionManager {
20
20
 
21
21
  // MARK: - Properties
22
22
 
23
- private var trackPlayerCore: TrackPlayerCore?
23
+ private weak var trackPlayerCore: TrackPlayerCore?
24
24
  private let artworkCache = NSCache<NSString, UIImage>()
25
25
 
26
26
  private var showInNotification: Bool = true
@@ -28,6 +28,11 @@ class MediaSessionManager {
28
28
  // Tracks the artwork URL currently shown so we can discard stale async loads
29
29
  private var lastArtworkUrl: String?
30
30
 
31
+ // Cached values received from playerQueue — main-thread-only reads (no sync needed)
32
+ private var cachedTrack: TrackItem?
33
+ private var cachedState: PlayerState?
34
+ private var cachedQueue: [TrackItem] = []
35
+
31
36
  init() {
32
37
  setupRemoteCommandCenter()
33
38
  }
@@ -47,27 +52,27 @@ class MediaSessionManager {
47
52
  refresh()
48
53
  }
49
54
 
50
- // MARK: - Single refresh entry point
55
+ // MARK: - Entry point from playerQueue (called via DispatchQueue.main.async)
51
56
  //
52
- // All public callbacks route here. Always dispatches to main thread so
53
- // MPNowPlayingInfoCenter and MPRemoteCommandCenter are only touched from main.
57
+ // Receives pre-computed values captured on playerQueue no player access here.
58
+
59
+ func updateFromPlayerQueue(track: TrackItem, state: PlayerState, queue: [TrackItem]) {
60
+ cachedTrack = track
61
+ cachedState = state
62
+ cachedQueue = queue
63
+ refreshInternal()
64
+ }
65
+
66
+ // MARK: - Refresh using cached values (main thread only)
54
67
 
55
68
  func refresh() {
56
69
  if Thread.isMainThread {
57
70
  refreshInternal()
58
71
  } else {
59
- DispatchQueue.main.async { [weak self] in
60
- self?.refreshInternal()
61
- }
72
+ DispatchQueue.main.async { [weak self] in self?.refreshInternal() }
62
73
  }
63
74
  }
64
75
 
65
- // Convenience aliases used by TrackPlayerCore call sites
66
- func updateNowPlayingInfo() { refresh() }
67
- func onTrackChanged() { refresh() }
68
- func onPlaybackStateChanged() { refresh() }
69
- func onQueueChanged() { refresh() }
70
-
71
76
  // MARK: - Core internal update (main thread only)
72
77
 
73
78
  private func refreshInternal() {
@@ -77,21 +82,13 @@ class MediaSessionManager {
77
82
  return
78
83
  }
79
84
 
80
- guard let core = trackPlayerCore,
81
- let track = core.getCurrentTrack()
82
- else {
85
+ guard let track = cachedTrack, let state = cachedState else {
83
86
  clearNowPlayingInfo()
84
87
  disableAllCommands()
85
88
  return
86
89
  }
87
90
 
88
- // Fetch snapshot once — both calls are cheap on main thread (no sync overhead)
89
- let state = core.getState()
90
- let queue = core.getActualQueue()
91
-
92
- // Find the actual position of the current track inside the actual queue.
93
- // state.currentIndex is the original-playlist index which is wrong when a
94
- // temp (playNext / upNext) track is playing.
91
+ let queue = cachedQueue
95
92
  let positionInQueue = queue.firstIndex(where: { $0.id == track.id }) ?? -1
96
93
 
97
94
  updateNowPlayingInfoInternal(track: track, state: state, queue: queue, positionInQueue: positionInQueue)
@@ -145,7 +142,6 @@ class MediaSessionManager {
145
142
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
146
143
  loadArtwork(url: artworkUrl) { [weak self] image in
147
144
  guard let self = self, let image = image else { return }
148
- // Discard if track changed while loading
149
145
  guard self.lastArtworkUrl == artworkUrl else { return }
150
146
  var updated = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
151
147
  updated[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
@@ -168,8 +164,6 @@ class MediaSessionManager {
168
164
  private func setupRemoteCommandCenter() {
169
165
  let commandCenter = MPRemoteCommandCenter.shared()
170
166
 
171
- // Clear any previously registered targets before adding fresh ones.
172
- // Prevents duplicate handlers if this were ever called more than once.
173
167
  commandCenter.playCommand.removeTarget(nil)
174
168
  commandCenter.pauseCommand.removeTarget(nil)
175
169
  commandCenter.togglePlayPauseCommand.removeTarget(nil)
@@ -183,7 +177,7 @@ class MediaSessionManager {
183
177
  commandCenter.playCommand.isEnabled = true
184
178
  commandCenter.playCommand.addTarget { [weak self] _ in
185
179
  guard let core = self?.trackPlayerCore else { return .commandFailed }
186
- core.play()
180
+ Task { await core.play() }
187
181
  return .success
188
182
  }
189
183
 
@@ -191,7 +185,7 @@ class MediaSessionManager {
191
185
  commandCenter.pauseCommand.isEnabled = true
192
186
  commandCenter.pauseCommand.addTarget { [weak self] _ in
193
187
  guard let core = self?.trackPlayerCore else { return .commandFailed }
194
- core.pause()
188
+ Task { await core.pause() }
195
189
  return .success
196
190
  }
197
191
 
@@ -199,51 +193,45 @@ class MediaSessionManager {
199
193
  commandCenter.togglePlayPauseCommand.isEnabled = true
200
194
  commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
201
195
  guard let core = self?.trackPlayerCore else { return .commandFailed }
202
- if core.getState().currentState == .playing {
203
- core.pause()
204
- } else {
205
- core.play()
206
- }
196
+ let isPlaying = self?.cachedState?.currentState == .playing
197
+ Task { if isPlaying { await core.pause() } else { await core.play() } }
207
198
  return .success
208
199
  }
209
200
 
210
- // Next track — isEnabled managed dynamically in updateCommandCenterState
201
+ // Next track
211
202
  commandCenter.nextTrackCommand.isEnabled = false
212
203
  commandCenter.nextTrackCommand.addTarget { [weak self] _ in
213
204
  guard let core = self?.trackPlayerCore else { return .commandFailed }
214
- core.skipToNext()
205
+ Task { await core.skipToNext() }
215
206
  return .success
216
207
  }
217
208
 
218
- // Previous track — isEnabled managed dynamically in updateCommandCenterState
209
+ // Previous track
219
210
  commandCenter.previousTrackCommand.isEnabled = false
220
211
  commandCenter.previousTrackCommand.addTarget { [weak self] _ in
221
212
  guard let core = self?.trackPlayerCore else { return .commandFailed }
222
- core.skipToPrevious()
213
+ Task { await core.skipToPrevious() }
223
214
  return .success
224
215
  }
225
216
 
226
- // Disable skip-forward/backward — these replace the scrubber with non-interactive buttons
227
217
  commandCenter.seekForwardCommand.isEnabled = false
228
218
  commandCenter.seekBackwardCommand.isEnabled = false
229
219
 
230
- // Scrubber — isEnabled managed dynamically based on known duration
220
+ // Scrubber
231
221
  commandCenter.changePlaybackPositionCommand.isEnabled = false
232
222
  commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
233
- guard let self = self,
234
- let core = self.trackPlayerCore,
223
+ guard let core = self?.trackPlayerCore,
235
224
  let positionEvent = event as? MPChangePlaybackPositionCommandEvent
236
225
  else {
237
226
  return .commandFailed
238
227
  }
239
- // Optimistically freeze the scrubber at the tapped position while the async
240
- // seek is in flight — updateNowPlayingInfo in the seek completion restores it.
228
+ // Optimistically freeze the scrubber at the tapped position
241
229
  if var info = MPNowPlayingInfoCenter.default().nowPlayingInfo {
242
230
  info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = positionEvent.positionTime
243
231
  info[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
244
232
  MPNowPlayingInfoCenter.default().nowPlayingInfo = info
245
233
  }
246
- core.seek(position: positionEvent.positionTime)
234
+ Task { await core.seek(position: positionEvent.positionTime) }
247
235
  return .success
248
236
  }
249
237
  }
@@ -260,13 +248,8 @@ class MediaSessionManager {
260
248
  let playerDuration = state.totalDuration
261
249
  let hasDuration = playerDuration > 0 && !playerDuration.isNaN && !playerDuration.isInfinite
262
250
 
263
- // Next: only enabled when there is a track after the current one
264
251
  commandCenter.nextTrackCommand.isEnabled = hasCurrentTrack && isNotLast
265
-
266
- // Previous: always enabled when something is playing — either restarts current or goes back
267
252
  commandCenter.previousTrackCommand.isEnabled = hasCurrentTrack
268
-
269
- // Scrubber: only enabled when we have a known, finite duration
270
253
  commandCenter.changePlaybackPositionCommand.isEnabled = hasCurrentTrack && hasDuration
271
254
  }
272
255
 
@@ -268,9 +268,6 @@ class PlaylistManager {
268
268
 
269
269
  currentPlaylistId = playlistId
270
270
 
271
- // Update TrackPlayerCore
272
- TrackPlayerCore.shared.loadPlaylist(playlistId: playlistId)
273
-
274
271
  return true
275
272
  }
276
273
 
@@ -399,9 +396,7 @@ class PlaylistManager {
399
396
  let (allPlaylists, currentListeners) = queue.sync {
400
397
  (Array(playlists.values), listeners)
401
398
  }
402
- DispatchQueue.main.async {
403
- currentListeners.forEach { $0.1(allPlaylists, operation) }
404
- }
399
+ currentListeners.forEach { $0.1(allPlaylists, operation) }
405
400
  }
406
401
 
407
402
  private func notifyPlaylistChanged(_ playlistId: String, _ operation: QueueOperation?) {
@@ -413,9 +408,7 @@ class PlaylistManager {
413
408
 
414
409
  guard let (playlist, currentListeners) = result else { return }
415
410
 
416
- DispatchQueue.main.async {
417
- currentListeners.forEach { $0.1(playlist, operation) }
418
- }
411
+ currentListeners.forEach { $0.1(playlist, operation) }
419
412
  }
420
413
 
421
414
  private func scheduleSave() {