react-native-nitro-player 1.5.0 โ†’ 1.5.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 (33) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +71 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +82 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  12. package/ios/HybridTrackPlayer.swift +52 -29
  13. package/ios/core/TrackPlayerCore.swift +9 -1
  14. package/ios/core/TrackPlayerListener.swift +20 -5
  15. package/ios/core/TrackPlayerNotify.swift +9 -7
  16. package/ios/core/TrackPlayerPlayback.swift +75 -39
  17. package/ios/core/TrackPlayerQueue.swift +28 -4
  18. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  19. package/ios/core/TrackPlayerRecovery.swift +1 -1
  20. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  21. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  22. package/ios/download/DownloadDatabase.swift +41 -13
  23. package/ios/media/MediaSessionManager.swift +19 -12
  24. package/ios/queue/HybridPlayerQueue.swift +10 -5
  25. package/lib/hooks/callbackManager.d.ts +17 -0
  26. package/lib/hooks/callbackManager.js +67 -1
  27. package/lib/hooks/useActualQueue.js +18 -12
  28. package/lib/hooks/useNowPlaying.js +10 -2
  29. package/package.json +1 -1
  30. package/src/components/CastButton.tsx +14 -2
  31. package/src/hooks/callbackManager.ts +87 -1
  32. package/src/hooks/useActualQueue.ts +18 -13
  33. package/src/hooks/useNowPlaying.ts +12 -2
@@ -9,159 +9,166 @@ import Foundation
9
9
  extension TrackPlayerCore {
10
10
 
11
11
  func loadPlaylist(playlistId: String, startIndex: Int? = nil) async {
12
- await withPlayerQueueNoThrow {
13
- self.playNextStack.removeAll()
14
- self.upNextQueue.removeAll()
15
- self.currentTemporaryType = .none
16
-
17
- NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength))
18
- NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ“‚ LOAD PLAYLIST REQUEST")
19
- NitroPlayerLogger.log("TrackPlayerCore", " Playlist ID: \(playlistId)")
20
- NitroPlayerLogger.log("TrackPlayerCore", " ๐Ÿงน Cleared temporary tracks")
21
-
22
- guard let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else {
23
- NitroPlayerLogger.log("TrackPlayerCore", " โŒ Playlist NOT FOUND")
24
- NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength) + "\n")
25
- return
26
- }
12
+ await withPlayerQueueNoThrow { self.loadPlaylistOnQueue(playlistId: playlistId, startIndex: startIndex) }
13
+ }
27
14
 
28
- NitroPlayerLogger.log("TrackPlayerCore", " โœ… Found playlist: \(playlist.name)")
29
- NitroPlayerLogger.log("TrackPlayerCore", " ๐Ÿ“‹ Contains \(playlist.tracks.count) tracks:")
30
- for (index, track) in playlist.tracks.enumerated() {
31
- NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] \(track.title) - \(track.artist)")
32
- }
15
+ func loadPlaylistOnQueue(playlistId: String, startIndex: Int? = nil) {
16
+ self.playNextStack.removeAll()
17
+ self.upNextQueue.removeAll()
18
+ self.currentTemporaryType = .none
19
+
20
+ NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength))
21
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ“‚ LOAD PLAYLIST REQUEST")
22
+ NitroPlayerLogger.log("TrackPlayerCore", " Playlist ID: \(playlistId)")
23
+ NitroPlayerLogger.log("TrackPlayerCore", " ๐Ÿงน Cleared temporary tracks")
24
+
25
+ guard let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else {
26
+ NitroPlayerLogger.log("TrackPlayerCore", " โŒ Playlist NOT FOUND")
33
27
  NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength) + "\n")
28
+ return
29
+ }
34
30
 
35
- let targetIndex: Int
36
- if let startIndex {
37
- guard startIndex >= 0 && startIndex < playlist.tracks.count else {
38
- NitroPlayerLogger.log("TrackPlayerCore", " โŒ Invalid start index: \(startIndex) (track count: \(playlist.tracks.count))")
39
- return
40
- }
41
- targetIndex = startIndex
42
- } else {
43
- targetIndex = 0
44
- }
31
+ NitroPlayerLogger.log("TrackPlayerCore", " โœ… Found playlist: \(playlist.name)")
32
+ NitroPlayerLogger.log("TrackPlayerCore", " ๐Ÿ“‹ Contains \(playlist.tracks.count) tracks:")
33
+ for (index, track) in playlist.tracks.enumerated() {
34
+ NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] \(track.title) - \(track.artist)")
35
+ }
36
+ NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "๐ŸŽผ", count: Constants.playlistSeparatorLength) + "\n")
45
37
 
46
- self.currentPlaylistId = playlistId
47
- if targetIndex == 0 {
48
- self.updatePlayerQueue(tracks: playlist.tracks)
49
- } else {
50
- // Bypass updatePlayerQueue to avoid emitting a spurious onTrackChange for index 0.
51
- // Set currentTracks directly so rebuildQueueFromPlaylistIndex can use them.
52
- self.currentTracks = playlist.tracks
53
- self.preloadedAssets.removeAll()
54
- _ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
38
+ let targetIndex: Int
39
+ if let startIndex {
40
+ guard startIndex >= 0 && startIndex < playlist.tracks.count else {
41
+ NitroPlayerLogger.log("TrackPlayerCore", " โŒ Invalid start index: \(startIndex) (track count: \(playlist.tracks.count))")
42
+ return
55
43
  }
56
- self.emitStateChange()
57
- self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
58
- self.notifyTemporaryQueueChange()
44
+ targetIndex = startIndex
45
+ } else {
46
+ targetIndex = 0
47
+ }
48
+
49
+ self.currentPlaylistId = playlistId
50
+ if targetIndex == 0 {
51
+ self.updatePlayerQueue(tracks: playlist.tracks)
52
+ } else {
53
+ // Bypass updatePlayerQueue to avoid emitting a spurious onTrackChange for index 0.
54
+ // Set currentTracks directly so rebuildQueueFromPlaylistIndex can use them.
55
+ self.currentTracks = playlist.tracks
56
+ for asset in self.preloadedAssets.values { asset.cancelLoading() }
57
+ self.preloadedAssets.removeAll()
58
+ _ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
59
59
  }
60
+ self.emitStateChange()
61
+ self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
62
+ self.notifyTemporaryQueueChange()
60
63
  }
61
64
 
65
+ /// Callable from any thread (PlaylistManager runs on its own queue) โ€” all state
66
+ /// access happens on playerQueue.
62
67
  func updatePlaylist(playlistId: String) {
63
- guard currentPlaylistId == playlistId else { return }
64
-
65
- // Cancel any pending rebuild so back-to-back calls collapse into a single rebuild.
66
- pendingPlaylistUpdateWorkItem?.cancel()
67
-
68
- let workItem = DispatchWorkItem { [weak self] in
69
- guard let self, self.currentPlaylistId == playlistId,
70
- let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else { return }
68
+ playerQueue.async { [weak self] in
69
+ guard let self, self.currentPlaylistId == playlistId else { return }
70
+
71
+ self.playlistUpdateGeneration &+= 1
72
+ let generation = self.playlistUpdateGeneration
73
+
74
+ // Run at the tail of the current burst; stale generations drop out.
75
+ self.playerQueue.async { [weak self] in
76
+ guard let self, generation == self.playlistUpdateGeneration,
77
+ self.currentPlaylistId == playlistId,
78
+ let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else { return }
79
+
80
+ // If nothing is playing yet, do a full load
81
+ guard self.player?.currentItem != nil else {
82
+ self.updatePlayerQueue(tracks: playlist.tracks)
83
+ self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
84
+ return
85
+ }
71
86
 
72
- // If nothing is playing yet, do a full load
73
- guard self.player?.currentItem != nil else {
74
- self.updatePlayerQueue(tracks: playlist.tracks)
87
+ // Update tracks list without interrupting playback
88
+ self.currentTracks = playlist.tracks
89
+ self.rebuildAVQueueFromCurrentPosition()
75
90
  self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
76
- return
77
91
  }
78
-
79
- // Update tracks list without interrupting playback
80
- self.currentTracks = playlist.tracks
81
- self.rebuildAVQueueFromCurrentPosition()
82
- self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
83
92
  }
84
-
85
- pendingPlaylistUpdateWorkItem = workItem
86
- playerQueue.async(execute: workItem)
87
93
  }
88
94
 
89
- func playNext(trackId: String) async throws {
90
- try await withPlayerQueue {
91
- guard let track = self.findTrackById(trackId) else {
92
- throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
93
- }
94
- NitroPlayerLogger.log("TrackPlayerCore", "โญ๏ธ playNext(\(trackId))")
95
- self.playNextStack.insert(track, at: 0)
96
- NitroPlayerLogger.log("TrackPlayerCore", " โœ… Added '\(track.title)' to playNext stack (position: 1)")
97
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
98
- self.notifyTemporaryQueueChange()
95
+ func playNextOnQueue(trackId: String) throws {
96
+ guard let track = findTrackById(trackId) else {
97
+ throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
99
98
  }
99
+ NitroPlayerLogger.log("TrackPlayerCore", "โญ๏ธ playNext(\(trackId))")
100
+ playNextStack.insert(track, at: 0)
101
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
102
+ notifyTemporaryQueueChange()
103
+ }
104
+ func playNext(trackId: String) async throws {
105
+ try await withPlayerQueue { try self.playNextOnQueue(trackId: trackId) }
100
106
  }
101
107
 
102
- func addToUpNext(trackId: String) async throws {
103
- try await withPlayerQueue {
104
- guard let track = self.findTrackById(trackId) else {
105
- throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
106
- }
107
- NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ“‹ addToUpNext(\(trackId))")
108
- self.upNextQueue.append(track)
109
- NitroPlayerLogger.log("TrackPlayerCore", " โœ… Added '\(track.title)' to upNext queue (position: \(self.upNextQueue.count))")
110
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
111
- self.notifyTemporaryQueueChange()
108
+ func addToUpNextOnQueue(trackId: String) throws {
109
+ guard let track = findTrackById(trackId) else {
110
+ throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
112
111
  }
112
+ NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ“‹ addToUpNext(\(trackId))")
113
+ upNextQueue.append(track)
114
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
115
+ notifyTemporaryQueueChange()
116
+ }
117
+ func addToUpNext(trackId: String) async throws {
118
+ try await withPlayerQueue { try self.addToUpNextOnQueue(trackId: trackId) }
113
119
  }
114
120
 
121
+ func removeFromPlayNextOnQueue(trackId: String) -> Bool {
122
+ guard let idx = playNextStack.firstIndex(where: { $0.id == trackId }) else { return false }
123
+ playNextStack.remove(at: idx)
124
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
125
+ notifyTemporaryQueueChange()
126
+ return true
127
+ }
115
128
  func removeFromPlayNext(trackId: String) async -> Bool {
116
- await withPlayerQueueNoThrow {
117
- guard let idx = self.playNextStack.firstIndex(where: { $0.id == trackId }) else { return false }
118
- self.playNextStack.remove(at: idx)
119
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
120
- self.notifyTemporaryQueueChange()
121
- return true
122
- }
129
+ await withPlayerQueueNoThrow { self.removeFromPlayNextOnQueue(trackId: trackId) }
123
130
  }
124
131
 
132
+ func removeFromUpNextOnQueue(trackId: String) -> Bool {
133
+ guard let idx = upNextQueue.firstIndex(where: { $0.id == trackId }) else { return false }
134
+ upNextQueue.remove(at: idx)
135
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
136
+ notifyTemporaryQueueChange()
137
+ return true
138
+ }
125
139
  func removeFromUpNext(trackId: String) async -> Bool {
126
- await withPlayerQueueNoThrow {
127
- guard let idx = self.upNextQueue.firstIndex(where: { $0.id == trackId }) else { return false }
128
- self.upNextQueue.remove(at: idx)
129
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
130
- self.notifyTemporaryQueueChange()
131
- return true
132
- }
140
+ await withPlayerQueueNoThrow { self.removeFromUpNextOnQueue(trackId: trackId) }
133
141
  }
134
142
 
135
- func clearPlayNext() async {
136
- await withPlayerQueueNoThrow {
137
- self.playNextStack.removeAll()
138
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
139
- self.notifyTemporaryQueueChange()
140
- }
143
+ func clearPlayNextOnQueue() {
144
+ playNextStack.removeAll()
145
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
146
+ notifyTemporaryQueueChange()
141
147
  }
148
+ func clearPlayNext() async { await withPlayerQueueNoThrow { self.clearPlayNextOnQueue() } }
142
149
 
143
- func clearUpNext() async {
144
- await withPlayerQueueNoThrow {
145
- self.upNextQueue.removeAll()
146
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
147
- self.notifyTemporaryQueueChange()
148
- }
150
+ func clearUpNextOnQueue() {
151
+ upNextQueue.removeAll()
152
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
153
+ notifyTemporaryQueueChange()
154
+ }
155
+ func clearUpNext() async { await withPlayerQueueNoThrow { self.clearUpNextOnQueue() } }
156
+
157
+ func reorderTemporaryTrackOnQueue(trackId: String, newIndex: Int) -> Bool {
158
+ var combined = playNextStack + upNextQueue
159
+ guard let fromIdx = combined.firstIndex(where: { $0.id == trackId }) else { return false }
160
+ let track = combined.remove(at: fromIdx)
161
+ let clamped = newIndex.clamped(to: 0...combined.count)
162
+ combined.insert(track, at: clamped)
163
+ let pnSize = playNextStack.count
164
+ playNextStack = Array(combined.prefix(pnSize))
165
+ upNextQueue = Array(combined.dropFirst(pnSize))
166
+ if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
167
+ notifyTemporaryQueueChange()
168
+ return true
149
169
  }
150
-
151
170
  func reorderTemporaryTrack(trackId: String, newIndex: Int) async -> Bool {
152
- await withPlayerQueueNoThrow {
153
- var combined = self.playNextStack + self.upNextQueue
154
- guard let fromIdx = combined.firstIndex(where: { $0.id == trackId }) else { return false }
155
- let track = combined.remove(at: fromIdx)
156
- let clamped = newIndex.clamped(to: 0...combined.count)
157
- combined.insert(track, at: clamped)
158
- let pnSize = self.playNextStack.count
159
- self.playNextStack = Array(combined.prefix(pnSize))
160
- self.upNextQueue = Array(combined.dropFirst(pnSize))
161
- if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
162
- self.notifyTemporaryQueueChange()
163
- return true
164
- }
171
+ await withPlayerQueueNoThrow { self.reorderTemporaryTrackOnQueue(trackId: trackId, newIndex: newIndex) }
165
172
  }
166
173
 
167
174
  func getPlayNextQueue() async -> [TrackItem] {
@@ -71,7 +71,7 @@ extension TrackPlayerCore {
71
71
  for trackId in updatedTrackIds {
72
72
  if self.preloadedAssets[trackId] != nil {
73
73
  NitroPlayerLogger.log("TrackPlayerCore", "๐Ÿ—‘๏ธ Invalidating preloaded asset for track: \(trackId)")
74
- self.preloadedAssets.removeValue(forKey: trackId)
74
+ self.preloadedAssets.removeValue(forKey: trackId)?.cancelLoading()
75
75
  }
76
76
  }
77
77
 
@@ -129,7 +129,7 @@ extension TrackPlayerCore {
129
129
  // No AVPlayerItem exists yet โ€” lazy-load mode: URLs were empty when the queue first loaded.
130
130
  NitroPlayerLogger.log("TrackPlayerCore",
131
131
  "๐Ÿ”„ No current item โ€” full queue rebuild from currentTrackIndex \(self.currentTrackIndex)")
132
- player.removeAllItems()
132
+ self.removeAllItemsCancellingLoads(player)
133
133
  var lastItem: AVPlayerItem? = nil
134
134
  for (offset, track) in self.currentTracks[max(0, self.currentTrackIndex)...].enumerated() {
135
135
  let isPreload = offset < Constants.gaplessPreloadCount
@@ -164,17 +164,41 @@ extension TrackPlayerCore {
164
164
  }
165
165
  }
166
166
 
167
+ /// Walks the queue structure directly instead of materializing the whole queue โ€”
168
+ /// this runs on every skip and on the periodic boundary observer, so it must not
169
+ /// be O(playlist) just to read the next few tracks.
167
170
  func getNextTracksInternal(count: Int) -> [TrackItem] {
168
- let actualQueue = getActualQueueInternal()
169
- guard !actualQueue.isEmpty else { return [] }
171
+ guard count > 0 else { return [] }
172
+ var out: [TrackItem] = []
173
+ out.reserveCapacity(count)
174
+ let currentId = activeCurrentTrackId
175
+
176
+ var skippedPlayNext = currentTemporaryType != .playNext
177
+ for track in playNextStack {
178
+ if !skippedPlayNext && track.id == currentId {
179
+ skippedPlayNext = true
180
+ continue
181
+ }
182
+ out.append(track)
183
+ if out.count == count { return out }
184
+ }
170
185
 
171
- guard let currentTrack = getCurrentTrack(),
172
- let currentIndex = actualQueue.firstIndex(where: { $0.id == currentTrack.id })
173
- else { return [] }
186
+ var skippedUpNext = currentTemporaryType != .upNext
187
+ for track in upNextQueue {
188
+ if !skippedUpNext && track.id == currentId {
189
+ skippedUpNext = true
190
+ continue
191
+ }
192
+ out.append(track)
193
+ if out.count == count { return out }
194
+ }
174
195
 
175
- let startIndex = currentIndex + 1
176
- let endIndex = min(startIndex + count, actualQueue.count)
177
- return startIndex < actualQueue.count ? Array(actualQueue[startIndex..<endIndex]) : []
196
+ var index = currentTrackIndex + 1
197
+ while index < currentTracks.count && out.count < count {
198
+ out.append(currentTracks[index])
199
+ index += 1
200
+ }
201
+ return out
178
202
  }
179
203
 
180
204
  func checkUpcomingTracksForUrls(lookahead: Int = 5) {
@@ -28,6 +28,39 @@ final class DownloadDatabase {
28
28
  private let queue = DispatchQueue(
29
29
  label: "com.nitroplayer.downloadDatabase", attributes: .concurrent)
30
30
 
31
+ /// Memoized `fileExists` verdicts, keyed by track ID.
32
+ ///
33
+ /// Queue building calls `isTrackDownloaded` / `getDownloadedTrack` once per track,
34
+ /// and each call used to stat() the file โ€” O(playlist) syscalls per rebuild, on the
35
+ /// thread that also drives playback. Downloads only change through this class (or
36
+ /// through the user deleting files behind our back, which `syncDownloads()` handles),
37
+ /// so the verdict is cached and invalidated on every mutation.
38
+ /// Guarded by its own lock rather than `queue` so it can be filled from inside
39
+ /// concurrent read blocks (`queue.sync`) without a barrier.
40
+ private var existenceCache: [String: Bool] = [:]
41
+ private let existenceCacheLock = NSLock()
42
+
43
+ private func cachedFileExists(trackId: String, path: @autoclosure () -> String) -> Bool {
44
+ existenceCacheLock.lock()
45
+ let cached = existenceCache[trackId]
46
+ existenceCacheLock.unlock()
47
+ if let cached { return cached }
48
+
49
+ let exists = FileManager.default.fileExists(atPath: path())
50
+ existenceCacheLock.lock()
51
+ existenceCache[trackId] = exists
52
+ existenceCacheLock.unlock()
53
+ return exists
54
+ }
55
+
56
+ /// Drops every memoized verdict. Called on any record mutation, and safe to call
57
+ /// externally when files may have changed outside the app.
58
+ func invalidateExistenceCache() {
59
+ existenceCacheLock.lock()
60
+ existenceCache.removeAll()
61
+ existenceCacheLock.unlock()
62
+ }
63
+
31
64
  // MARK: - Initialization
32
65
 
33
66
  private init() {
@@ -70,22 +103,14 @@ final class DownloadDatabase {
70
103
  NitroPlayerLogger.log("DownloadDatabase", "๐Ÿ” Track \(trackId) NOT found in database")
71
104
  return false
72
105
  }
73
- // Verify file still exists
74
- let absolutePath = resolveAbsolutePath(for: record)
75
- let exists = FileManager.default.fileExists(atPath: absolutePath)
76
- if exists {
77
- NitroPlayerLogger.log("DownloadDatabase", "โœ… Track \(trackId) IS downloaded at \(absolutePath)")
78
- } else {
79
- NitroPlayerLogger.log("DownloadDatabase", "โŒ Track \(trackId) record exists but file NOT found at \(absolutePath)")
80
- }
81
- return exists
106
+ // Verify file still exists (memoized โ€” this runs once per track per queue rebuild)
107
+ return cachedFileExists(trackId: trackId, path: resolveAbsolutePath(for: record))
82
108
  }
83
109
  }
84
110
 
85
111
  private func _isTrackDownloadedUnsafe(trackId: String) -> Bool {
86
112
  guard let record = downloadedTracks[trackId] else { return false }
87
- let absolutePath = resolveAbsolutePath(for: record)
88
- return FileManager.default.fileExists(atPath: absolutePath)
113
+ return cachedFileExists(trackId: trackId, path: resolveAbsolutePath(for: record))
89
114
  }
90
115
 
91
116
  func isPlaylistDownloaded(playlistId: String) -> Bool {
@@ -137,8 +162,8 @@ final class DownloadDatabase {
137
162
  let absolutePath = resolveAbsolutePath(for: record)
138
163
  NitroPlayerLogger.log("DownloadDatabase", " Found record, checking file at: \(absolutePath)")
139
164
 
140
- // Verify file still exists
141
- guard FileManager.default.fileExists(atPath: absolutePath) else {
165
+ // Verify file still exists (memoized)
166
+ guard cachedFileExists(trackId: trackId, path: absolutePath) else {
142
167
  NitroPlayerLogger.log("DownloadDatabase", " โŒ File does NOT exist, cleaning up record")
143
168
  // File was deleted externally, clean up record
144
169
  queue.async(flags: .barrier) {
@@ -357,6 +382,9 @@ final class DownloadDatabase {
357
382
  // MARK: - Persistence
358
383
 
359
384
  private func saveToDisk() {
385
+ // Every record mutation funnels through here, so this is the one place that has
386
+ // to drop memoized fileExists verdicts.
387
+ invalidateExistenceCache()
360
388
  do {
361
389
  let tracksData = try JSONEncoder().encode(downloadedTracks)
362
390
  // Convert Set to Array for encoding
@@ -31,7 +31,11 @@ class MediaSessionManager {
31
31
  // Cached values received from playerQueue โ€” main-thread-only reads (no sync needed)
32
32
  private var cachedTrack: TrackItem?
33
33
  private var cachedState: PlayerState?
34
- private var cachedQueue: [TrackItem] = []
34
+ // Only the queue length and the current track's position are needed โ€” never the
35
+ // items themselves. Copying the whole queue on every state change was O(playlist)
36
+ // per event; these two Ints are computed on playerQueue instead.
37
+ private var cachedQueueCount: Int = 0
38
+ private var cachedPositionInQueue: Int = -1
35
39
 
36
40
  init() {
37
41
  setupRemoteCommandCenter()
@@ -56,10 +60,13 @@ class MediaSessionManager {
56
60
  //
57
61
  // Receives pre-computed values captured on playerQueue โ€” no player access here.
58
62
 
59
- func updateFromPlayerQueue(track: TrackItem, state: PlayerState, queue: [TrackItem]) {
63
+ func updateFromPlayerQueue(
64
+ track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int
65
+ ) {
60
66
  cachedTrack = track
61
67
  cachedState = state
62
- cachedQueue = queue
68
+ cachedQueueCount = queueCount
69
+ cachedPositionInQueue = positionInQueue
63
70
  refreshInternal()
64
71
  }
65
72
 
@@ -88,11 +95,11 @@ class MediaSessionManager {
88
95
  return
89
96
  }
90
97
 
91
- let queue = cachedQueue
92
- let positionInQueue = queue.firstIndex(where: { $0.id == track.id }) ?? -1
93
-
94
- updateNowPlayingInfoInternal(track: track, state: state, queue: queue, positionInQueue: positionInQueue)
95
- updateCommandCenterState(state: state, queue: queue, positionInQueue: positionInQueue)
98
+ updateNowPlayingInfoInternal(
99
+ track: track, state: state,
100
+ queueCount: cachedQueueCount, positionInQueue: cachedPositionInQueue)
101
+ updateCommandCenterState(
102
+ state: state, queueCount: cachedQueueCount, positionInQueue: cachedPositionInQueue)
96
103
  }
97
104
 
98
105
  // MARK: - Now Playing Info
@@ -100,7 +107,7 @@ class MediaSessionManager {
100
107
  private func updateNowPlayingInfoInternal(
101
108
  track: TrackItem,
102
109
  state: PlayerState,
103
- queue: [TrackItem],
110
+ queueCount: Int,
104
111
  positionInQueue: Int
105
112
  ) {
106
113
  let playerDuration = state.totalDuration
@@ -125,7 +132,7 @@ class MediaSessionManager {
125
132
  MPMediaItemPropertyPlaybackDuration: effectiveDuration,
126
133
  MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0,
127
134
  MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
128
- MPNowPlayingInfoPropertyPlaybackQueueCount: max(1, queue.count),
135
+ MPNowPlayingInfoPropertyPlaybackQueueCount: max(1, queueCount),
129
136
  MPNowPlayingInfoPropertyPlaybackQueueIndex: max(0, positionInQueue),
130
137
  ]
131
138
 
@@ -238,12 +245,12 @@ class MediaSessionManager {
238
245
 
239
246
  private func updateCommandCenterState(
240
247
  state: PlayerState,
241
- queue: [TrackItem],
248
+ queueCount: Int,
242
249
  positionInQueue: Int
243
250
  ) {
244
251
  let commandCenter = MPRemoteCommandCenter.shared()
245
252
  let hasCurrentTrack = positionInQueue >= 0
246
- let isNotLast = positionInQueue < queue.count - 1
253
+ let isNotLast = positionInQueue < queueCount - 1
247
254
 
248
255
  let playerDuration = state.totalDuration
249
256
  let hasDuration = playerDuration > 0 && !playerDuration.isNaN && !playerDuration.isInfinite
@@ -77,14 +77,19 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
77
77
 
78
78
  // MARK: - Playback control
79
79
 
80
+ /// Enqueued synchronously on the player queue so a `loadPlaylist()` followed by
81
+ /// `play()` from JS cannot be reordered โ€” see HybridTrackPlayer.enqueue.
80
82
  func loadPlaylist(playlistId: String, index: Double?) throws -> Promise<Void> {
81
- Promise.async {
82
- let startIndex = index.map { Int($0) }
83
+ let startIndex = index.map { Int($0) }
84
+ let promise = Promise<Void>()
85
+ core.playerQueue.async {
83
86
  // Update PlaylistManager.currentPlaylistId so getCurrentPlaylistId() returns correctly
84
- let loaded = self.playlistManager.loadPlaylist(playlistId: playlistId, index: startIndex)
85
- guard loaded else { return }
86
- await self.core.loadPlaylist(playlistId: playlistId, startIndex: startIndex)
87
+ if self.playlistManager.loadPlaylist(playlistId: playlistId, index: startIndex) {
88
+ self.core.loadPlaylistOnQueue(playlistId: playlistId, startIndex: startIndex)
89
+ }
90
+ promise.resolve(withResult: ())
87
91
  }
92
+ return promise
88
93
  }
89
94
 
90
95
  func getCurrentPlaylistId() throws -> Variant_NullType_String {
@@ -3,6 +3,7 @@ type PlaybackStateCallback = (state: TrackPlayerState, reason?: Reason) => void;
3
3
  type TrackChangeCallback = (track: TrackItem, reason?: Reason) => void;
4
4
  type PlaybackProgressCallback = (position: number, totalDuration: number, isManuallySeeked?: boolean) => void;
5
5
  type SeekCallback = (position: number, totalDuration: number) => void;
6
+ type TemporaryQueueCallback = (playNextQueue: TrackItem[], upNextQueue: TrackItem[]) => void;
6
7
  /**
7
8
  * Internal subscription manager that allows multiple hooks to subscribe
8
9
  * to a single native callback. This solves the problem where registering
@@ -13,10 +14,14 @@ declare class CallbackSubscriptionManager {
13
14
  private trackChangeSubscribers;
14
15
  private playbackProgressSubscribers;
15
16
  private seekSubscribers;
17
+ private temporaryQueueSubscribers;
18
+ private playlistsChangedSubscribers;
19
+ private isPlaylistsChangedRegistered;
16
20
  private isPlaybackStateRegistered;
17
21
  private isTrackChangeRegistered;
18
22
  private isPlaybackProgressRegistered;
19
23
  private isSeekRegistered;
24
+ private isTemporaryQueueRegistered;
20
25
  /**
21
26
  * Subscribe to playback state changes
22
27
  * @returns Unsubscribe function
@@ -40,6 +45,18 @@ declare class CallbackSubscriptionManager {
40
45
  */
41
46
  subscribeToSeek(callback: SeekCallback): () => void;
42
47
  private ensurePlaybackProgressRegistered;
48
+ /**
49
+ * Subscribe to playNext / upNext changes
50
+ * @returns Unsubscribe function
51
+ */
52
+ subscribeToTemporaryQueueChange(callback: TemporaryQueueCallback): () => void;
53
+ /**
54
+ * Subscribe to playlist mutations (tracks added/removed/reordered).
55
+ * @returns Unsubscribe function
56
+ */
57
+ subscribeToPlaylistsChanged(callback: () => void): () => void;
58
+ private ensurePlaylistsChangedRegistered;
59
+ private ensureTemporaryQueueRegistered;
43
60
  private ensureSeekRegistered;
44
61
  }
45
62
  export declare const callbackManager: CallbackSubscriptionManager;