react-native-nitro-player 1.5.0-alpha.3 โ 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.
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +71 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +82 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
- package/ios/HybridTrackPlayer.swift +52 -29
- package/ios/core/TrackPlayerCore.swift +9 -1
- package/ios/core/TrackPlayerListener.swift +20 -5
- package/ios/core/TrackPlayerNotify.swift +9 -7
- package/ios/core/TrackPlayerPlayback.swift +75 -39
- package/ios/core/TrackPlayerQueue.swift +28 -4
- package/ios/core/TrackPlayerQueueBuild.swift +100 -40
- package/ios/core/TrackPlayerRecovery.swift +1 -1
- package/ios/core/TrackPlayerTempQueue.swift +129 -122
- package/ios/core/TrackPlayerUrlLoader.swift +34 -10
- package/ios/download/DownloadDatabase.swift +41 -13
- package/ios/media/MediaSessionManager.swift +19 -12
- package/ios/queue/HybridPlayerQueue.swift +10 -5
- package/lib/hooks/callbackManager.d.ts +17 -0
- package/lib/hooks/callbackManager.js +67 -1
- package/lib/hooks/useActualQueue.js +18 -12
- package/lib/hooks/useNowPlaying.js +10 -2
- package/package.json +1 -1
- package/src/components/CastButton.tsx +14 -2
- package/src/hooks/callbackManager.ts +87 -1
- package/src/hooks/useActualQueue.ts +18 -13
- 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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
self.
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
63
|
+
func updateFromPlayerQueue(
|
|
64
|
+
track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int
|
|
65
|
+
) {
|
|
60
66
|
cachedTrack = track
|
|
61
67
|
cachedState = state
|
|
62
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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;
|