react-native-nitro-player 1.6.0-alpha.1 → 1.7.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +9 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +34 -17
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +78 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +22 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
- package/android/src/main/res/values/strings.xml +7 -0
- package/ios/HybridDownloadManager.swift +13 -3
- package/ios/HybridTrackPlayer.swift +8 -2
- package/ios/core/TrackPlayerCast.swift +32 -1
- package/ios/core/TrackPlayerCore.swift +19 -3
- package/ios/core/TrackPlayerListener.swift +15 -16
- package/ios/core/TrackPlayerNotify.swift +11 -11
- package/ios/core/TrackPlayerPlayback.swift +54 -16
- package/ios/core/TrackPlayerQueueBuild.swift +14 -2
- package/ios/core/TrackPlayerTempQueue.swift +1 -0
- package/ios/core/TrackPlayerUrlLoader.swift +11 -3
- package/ios/download/DownloadDatabase.swift +16 -5
- package/ios/download/DownloadFileManager.swift +12 -15
- package/ios/download/DownloadManagerCore.swift +61 -16
- package/ios/equalizer/EqualizerCore.swift +37 -9
- package/ios/media/MediaSessionManager.swift +89 -4
- package/ios/playlist/PlaylistManager.swift +47 -46
- package/ios/queue/HybridPlayerQueue.swift +28 -7
- package/lib/hooks/callbackManager.d.ts +9 -0
- package/lib/hooks/callbackManager.js +33 -0
- package/lib/hooks/useActualQueue.js +18 -6
- package/lib/hooks/useAndroidAutoConnection.js +2 -4
- package/lib/hooks/useAudioDevices.js +21 -24
- package/lib/hooks/useDownloadActions.js +33 -11
- package/lib/hooks/useDownloadProgress.js +20 -14
- package/lib/hooks/useDownloadStorage.js +1 -1
- package/lib/hooks/useNowPlaying.js +32 -6
- package/lib/hooks/useOnSeek.js +2 -2
- package/lib/hooks/usePlaylist.js +6 -15
- package/lib/specs/TrackPlayer.nitro.d.ts +6 -2
- package/lib/types/PlayerQueue.d.ts +8 -0
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_TrackItem.hpp +85 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TrackItem.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +22 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -0
- package/nitrogen/generated/ios/swift/Func_void_TrackItem.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +44 -0
- package/src/hooks/useActualQueue.ts +18 -6
- package/src/hooks/useAndroidAutoConnection.ts +6 -6
- package/src/hooks/useAudioDevices.ts +23 -24
- package/src/hooks/useDownloadActions.ts +35 -11
- package/src/hooks/useDownloadProgress.ts +22 -19
- package/src/hooks/useDownloadStorage.ts +4 -3
- package/src/hooks/useNowPlaying.ts +34 -6
- package/src/hooks/useOnSeek.ts +2 -2
- package/src/hooks/usePlaylist.ts +6 -14
- package/src/specs/TrackPlayer.nitro.ts +6 -2
- package/src/types/PlayerQueue.ts +8 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
- package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
- package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
|
@@ -169,12 +169,14 @@ extension TrackPlayerCore {
|
|
|
169
169
|
func playerItemDidPlayToEndTimeInternal(_ notification: Notification) {
|
|
170
170
|
NitroPlayerLogger.log("TrackPlayerCore", "\n🏁 Track finished playing")
|
|
171
171
|
guard let finishedItem = notification.object as? AVPlayerItem else { return }
|
|
172
|
+
// Items without our trackId belong to another AVPlayer in the host app
|
|
173
|
+
guard finishedItem.trackId != nil else { return }
|
|
172
174
|
|
|
173
175
|
// 1. TRACK repeat — handle FIRST, before any temp-track removal
|
|
174
|
-
if currentRepeatMode == .track {
|
|
176
|
+
if currentRepeatMode == .track, finishedItem === player?.currentItem {
|
|
175
177
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — seeking to zero and replaying")
|
|
176
178
|
player?.seek(to: .zero)
|
|
177
|
-
player?.
|
|
179
|
+
player?.rate = Float(currentPlaybackSpeed)
|
|
178
180
|
return // do not remove temp tracks, do not notify track change (same track looping)
|
|
179
181
|
}
|
|
180
182
|
|
|
@@ -201,14 +203,18 @@ extension TrackPlayerCore {
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
@objc func playerItemFailedToPlayToEndTime(_ notification: Notification) {
|
|
204
|
-
|
|
206
|
+
guard (notification.object as? AVPlayerItem)?.trackId != nil else { return }
|
|
207
|
+
guard let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
|
|
208
|
+
else { return }
|
|
209
|
+
playerQueue.async { [weak self] in
|
|
205
210
|
NitroPlayerLogger.log("TrackPlayerCore", "❌ Playback failed - \(error)")
|
|
206
|
-
notifyPlaybackStateChange(.stopped, .error)
|
|
211
|
+
self?.notifyPlaybackStateChange(.stopped, .error)
|
|
207
212
|
}
|
|
208
213
|
}
|
|
209
214
|
|
|
210
215
|
@objc func playerItemNewErrorLogEntry(_ notification: Notification) {
|
|
211
|
-
guard let item = notification.object as? AVPlayerItem,
|
|
216
|
+
guard let item = notification.object as? AVPlayerItem, item.trackId != nil,
|
|
217
|
+
let errorLog = item.errorLog() else { return }
|
|
212
218
|
for event in errorLog.events ?? [] {
|
|
213
219
|
NitroPlayerLogger.log("TrackPlayerCore", "❌ Error log - \(event.errorComment ?? "Unknown error") - Code: \(event.errorStatusCode)")
|
|
214
220
|
}
|
|
@@ -220,6 +226,7 @@ extension TrackPlayerCore {
|
|
|
220
226
|
@objc func playerItemTimeJumped(_ notification: Notification) {
|
|
221
227
|
playerQueue.async { [weak self] in
|
|
222
228
|
guard let self, let player = self.player, let currentItem = player.currentItem else { return }
|
|
229
|
+
guard (notification.object as? AVPlayerItem) === currentItem else { return }
|
|
223
230
|
let position = currentItem.currentTime().seconds
|
|
224
231
|
let duration = currentItem.duration.seconds
|
|
225
232
|
NitroPlayerLogger.log("TrackPlayerCore", "🎯 Time jumped (seek detected) - position: \(Int(position))s")
|
|
@@ -301,7 +308,7 @@ extension TrackPlayerCore {
|
|
|
301
308
|
lastItem = item
|
|
302
309
|
}
|
|
303
310
|
currentTrackIndex = 0
|
|
304
|
-
player.
|
|
311
|
+
player.rate = Float(currentPlaybackSpeed)
|
|
305
312
|
|
|
306
313
|
if let firstTrack = currentTracks.first {
|
|
307
314
|
notifyTrackChange(firstTrack, .repeat)
|
|
@@ -454,16 +461,8 @@ extension TrackPlayerCore {
|
|
|
454
461
|
self?.setupBoundaryTimeObserver()
|
|
455
462
|
// First item is buffered and ready — disable stall waiting for gapless inter-track transitions
|
|
456
463
|
self?.player?.automaticallyWaitsToMinimizeStalling = false
|
|
457
|
-
// Update now playing info now that duration is available (
|
|
458
|
-
|
|
459
|
-
let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
|
|
460
|
-
let track = self?.getCurrentTrack()
|
|
461
|
-
DispatchQueue.main.async {
|
|
462
|
-
if let track = track, let state = state {
|
|
463
|
-
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
464
|
-
track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
465
|
-
}
|
|
466
|
-
}
|
|
464
|
+
// Update now playing info now that duration is available (captured on playerQueue)
|
|
465
|
+
self?.refreshMediaSession()
|
|
467
466
|
} else if item.status == .failed {
|
|
468
467
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
469
468
|
"❌ Item failed — \(item.error?.localizedDescription ?? "unknown error")")
|
|
@@ -13,24 +13,24 @@ extension TrackPlayerCore {
|
|
|
13
13
|
// Called on playerQueue — invoke listeners directly (Nitro handles JS thread hop)
|
|
14
14
|
func notifyTrackChange(_ track: TrackItem, _ reason: Reason?) {
|
|
15
15
|
onChangeTrackListeners.forEach { $0(track, reason) }
|
|
16
|
-
|
|
17
|
-
// Metrics, not the queue itself — the media session only needs count and position.
|
|
18
|
-
let state = getStateInternal()
|
|
19
|
-
let metrics = actualQueueMetrics()
|
|
20
|
-
DispatchQueue.main.async { [weak self] in
|
|
21
|
-
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
22
|
-
track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
23
|
-
}
|
|
16
|
+
refreshMediaSession(track: track)
|
|
24
17
|
}
|
|
25
18
|
|
|
26
19
|
func notifyPlaybackStateChange(_ state: TrackPlayerState, _ reason: Reason?) {
|
|
27
20
|
onPlaybackStateChangeListeners.forEach { $0(state, reason) }
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
refreshMediaSession()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Must run on playerQueue — values are captured here and handed to main pre-computed
|
|
25
|
+
func refreshMediaSession(track providedTrack: TrackItem? = nil) {
|
|
26
|
+
guard let track = providedTrack ?? getCurrentTrack() else { return }
|
|
27
|
+
let state = getStateInternal()
|
|
30
28
|
let metrics = actualQueueMetrics()
|
|
29
|
+
let playbackSpeed = currentPlaybackSpeed
|
|
31
30
|
DispatchQueue.main.async { [weak self] in
|
|
32
31
|
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
33
|
-
track: track, state:
|
|
32
|
+
track: track, state: state, queueCount: metrics.count,
|
|
33
|
+
positionInQueue: metrics.position, playbackSpeed: playbackSpeed)
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -98,33 +98,49 @@ extension TrackPlayerCore {
|
|
|
98
98
|
|
|
99
99
|
func configureOnQueue(
|
|
100
100
|
androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
|
|
101
|
-
showInNotification: Bool?, lookaheadCount: Int
|
|
101
|
+
showInNotification: Bool?, lookaheadCount: Int?,
|
|
102
|
+
remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?
|
|
102
103
|
) {
|
|
103
104
|
if let la = lookaheadCount {
|
|
104
105
|
self.lookaheadCount = la
|
|
105
106
|
NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(la)")
|
|
106
107
|
}
|
|
108
|
+
let playbackSpeed = currentPlaybackSpeed
|
|
107
109
|
DispatchQueue.main.async { [weak self] in
|
|
108
110
|
self?.mediaSessionManager?.configure(
|
|
109
111
|
androidAutoEnabled: androidAutoEnabled,
|
|
110
112
|
carPlayEnabled: carPlayEnabled,
|
|
111
|
-
showInNotification: showInNotification
|
|
113
|
+
showInNotification: showInNotification,
|
|
114
|
+
remoteSkipForwardInterval: remoteSkipForwardInterval,
|
|
115
|
+
remoteSkipBackwardInterval: remoteSkipBackwardInterval,
|
|
116
|
+
playbackSpeed: playbackSpeed
|
|
112
117
|
)
|
|
113
118
|
}
|
|
114
119
|
}
|
|
115
120
|
func configure(
|
|
116
121
|
androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
|
|
117
|
-
showInNotification: Bool?, lookaheadCount: Int
|
|
122
|
+
showInNotification: Bool?, lookaheadCount: Int?,
|
|
123
|
+
remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?
|
|
118
124
|
) async {
|
|
119
125
|
await withPlayerQueueNoThrow {
|
|
120
126
|
self.configureOnQueue(
|
|
121
127
|
androidAutoEnabled: androidAutoEnabled, carPlayEnabled: carPlayEnabled,
|
|
122
|
-
showInNotification: showInNotification, lookaheadCount: lookaheadCount
|
|
128
|
+
showInNotification: showInNotification, lookaheadCount: lookaheadCount,
|
|
129
|
+
remoteSkipForwardInterval: remoteSkipForwardInterval,
|
|
130
|
+
remoteSkipBackwardInterval: remoteSkipBackwardInterval)
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
func setPlaybackSpeedOnQueue(_ speed: Double) {
|
|
134
|
+
func setPlaybackSpeedOnQueue(_ speed: Double) throws {
|
|
135
|
+
guard speed.isFinite, speed > 0 else {
|
|
136
|
+
throw NSError(
|
|
137
|
+
domain: "NitroPlayer",
|
|
138
|
+
code: -1,
|
|
139
|
+
userInfo: [NSLocalizedDescriptionKey: "Speed must be finite and greater than 0"]
|
|
140
|
+
)
|
|
141
|
+
}
|
|
127
142
|
currentPlaybackSpeed = speed
|
|
143
|
+
defer { refreshMediaSession() }
|
|
128
144
|
if isCasting {
|
|
129
145
|
castManager?.setPlaybackRate(Float(speed))
|
|
130
146
|
return
|
|
@@ -134,8 +150,30 @@ extension TrackPlayerCore {
|
|
|
134
150
|
player.rate = Float(speed)
|
|
135
151
|
}
|
|
136
152
|
}
|
|
137
|
-
func setPlaybackSpeed(_ speed: Double) async {
|
|
138
|
-
await
|
|
153
|
+
func setPlaybackSpeed(_ speed: Double) async throws {
|
|
154
|
+
try await withPlayerQueue { try self.setPlaybackSpeedOnQueue(speed) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func seekByOnQueue(offset: Double) {
|
|
158
|
+
if isCasting {
|
|
159
|
+
let position = castManager?.lastKnownRemotePosition ?? 0
|
|
160
|
+
let duration = castManager?.lastKnownRemoteDuration ?? 0
|
|
161
|
+
var target = max(0, position + offset)
|
|
162
|
+
if duration > 0, duration.isFinite { target = min(target, duration) }
|
|
163
|
+
castManager?.seek(to: target)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
guard let player = self.player else { return }
|
|
167
|
+
let position = player.currentTime().seconds
|
|
168
|
+
guard position.isFinite else { return }
|
|
169
|
+
var target = max(0, position + offset)
|
|
170
|
+
if let duration = player.currentItem?.duration.seconds, duration > 0, duration.isFinite {
|
|
171
|
+
target = min(target, duration)
|
|
172
|
+
}
|
|
173
|
+
seekInternal(position: target)
|
|
174
|
+
}
|
|
175
|
+
func seekBy(offset: Double) async {
|
|
176
|
+
await withPlayerQueueNoThrow { self.seekByOnQueue(offset: offset) }
|
|
139
177
|
}
|
|
140
178
|
|
|
141
179
|
func getPlaybackSpeed() async -> Double {
|
|
@@ -150,6 +188,7 @@ extension TrackPlayerCore {
|
|
|
150
188
|
|
|
151
189
|
func playInternal() {
|
|
152
190
|
NitroPlayerLogger.log("TrackPlayerCore", "▶️ play() called")
|
|
191
|
+
activateAudioSessionIfNeeded()
|
|
153
192
|
self.intendedToPlay = true
|
|
154
193
|
// An explicit play() is the ground truth for intent — clear any stale
|
|
155
194
|
// interruption flag so a missed AVAudioSession `.ended` can't permanently
|
|
@@ -187,7 +226,7 @@ extension TrackPlayerCore {
|
|
|
187
226
|
guard let player = self.player else { return }
|
|
188
227
|
self.isManuallySeeked = true
|
|
189
228
|
let time = CMTime(seconds: position, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
|
190
|
-
player.seek(to: time) {
|
|
229
|
+
player.seek(to: time) { _ in
|
|
191
230
|
// HackFix I dont know how to fix this, but it works.
|
|
192
231
|
let rate = Double(player.rate)
|
|
193
232
|
DispatchQueue.main.async {
|
|
@@ -197,10 +236,8 @@ extension TrackPlayerCore {
|
|
|
197
236
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
198
237
|
}
|
|
199
238
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
self?.notifySeek(position, duration)
|
|
203
|
-
}
|
|
239
|
+
// No notifySeek here — the same seek fires AVPlayerItemTimeJumped,
|
|
240
|
+
// whose handler already emits it; calling both doubled every onSeek.
|
|
204
241
|
}
|
|
205
242
|
}
|
|
206
243
|
|
|
@@ -240,11 +277,12 @@ extension TrackPlayerCore {
|
|
|
240
277
|
|
|
241
278
|
if queuePlayer.items().count > 1 {
|
|
242
279
|
queuePlayer.advanceToNextItem()
|
|
280
|
+
} else if currentRepeatMode == .playlist, !currentTracks.isEmpty {
|
|
281
|
+
// Manual skip at the last track wraps, matching natural-end behavior
|
|
282
|
+
_ = skipToIndexInternal(index: 0)
|
|
243
283
|
} else {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
self.isRecoveringFromStall = false
|
|
247
|
-
self.notifyPlaybackStateChange(.stopped, .end)
|
|
284
|
+
// No next track — no-op and keep playing, matching Android
|
|
285
|
+
NitroPlayerLogger.log("TrackPlayerCore", "⏭️ skipToNext at last track — nothing to advance to")
|
|
248
286
|
}
|
|
249
287
|
|
|
250
288
|
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
@@ -85,6 +85,7 @@ extension TrackPlayerCore {
|
|
|
85
85
|
// Clear old preloaded assets when loading new queue
|
|
86
86
|
for asset in preloadedAssets.values { asset.cancelLoading() }
|
|
87
87
|
preloadedAssets.removeAll()
|
|
88
|
+
preloadGeneration += 1
|
|
88
89
|
|
|
89
90
|
guard let existingPlayer = self.player else {
|
|
90
91
|
NitroPlayerLogger.log("TrackPlayerCore", "❌ No player available")
|
|
@@ -262,7 +263,7 @@ extension TrackPlayerCore {
|
|
|
262
263
|
|
|
263
264
|
self.preloadUpcomingTracks(from: index + 1)
|
|
264
265
|
|
|
265
|
-
if wasPlaying { player.
|
|
266
|
+
if wasPlaying { player.rate = Float(currentPlaybackSpeed) }
|
|
266
267
|
return true
|
|
267
268
|
}
|
|
268
269
|
|
|
@@ -569,6 +570,7 @@ extension TrackPlayerCore {
|
|
|
569
570
|
// Swift Array/Dictionary are not safe for concurrent access.
|
|
570
571
|
let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
|
|
571
572
|
let alreadyPreloaded = Set(preloadedAssets.keys)
|
|
573
|
+
let generation = preloadGeneration
|
|
572
574
|
let endIndex = min(startIndex + Constants.gaplessPreloadCount, currentTracks.count)
|
|
573
575
|
guard startIndex >= 0, startIndex < endIndex else { return }
|
|
574
576
|
let candidates = Array(currentTracks[startIndex..<endIndex])
|
|
@@ -607,7 +609,17 @@ extension TrackPlayerCore {
|
|
|
607
609
|
|
|
608
610
|
if allKeysLoaded {
|
|
609
611
|
self?.playerQueue.async {
|
|
610
|
-
self
|
|
612
|
+
guard let self else { return }
|
|
613
|
+
// Stale completion: the queue was torn down (or this track left it)
|
|
614
|
+
// while the asset was still loading — publishing would hand a dead
|
|
615
|
+
// or wrong-URL asset to createGaplessPlayerItem
|
|
616
|
+
guard self.preloadGeneration == generation,
|
|
617
|
+
self.currentTracks.contains(where: { $0.id == track.id })
|
|
618
|
+
else {
|
|
619
|
+
asset.cancelLoading()
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
self.preloadedAssets[track.id] = asset
|
|
611
623
|
NitroPlayerLogger.log("TrackPlayerCore", "🎯 Preloaded asset for upcoming track: \(track.title)")
|
|
612
624
|
}
|
|
613
625
|
}
|
|
@@ -55,6 +55,7 @@ extension TrackPlayerCore {
|
|
|
55
55
|
self.currentTracks = playlist.tracks
|
|
56
56
|
for asset in self.preloadedAssets.values { asset.cancelLoading() }
|
|
57
57
|
self.preloadedAssets.removeAll()
|
|
58
|
+
self.preloadGeneration += 1
|
|
58
59
|
_ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
|
|
59
60
|
}
|
|
60
61
|
self.emitStateChange()
|
|
@@ -79,7 +79,9 @@ extension TrackPlayerCore {
|
|
|
79
79
|
let affectedPlaylists = self.playlistManager.updateTracks(tracks: safeTracks)
|
|
80
80
|
|
|
81
81
|
// Replace the current AVPlayerItem when its URL just resolved (local only — cast reloads below instead).
|
|
82
|
-
|
|
82
|
+
// Look up the FRESH track — the pre-update snapshot's URL is empty by definition here.
|
|
83
|
+
if !self.isCasting, currentTrackIsEmpty,
|
|
84
|
+
let update = safeTracks.first(where: { $0.id == currentTrackId }), !update.url.isEmpty {
|
|
83
85
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
84
86
|
"🔄 Replacing current AVPlayerItem for track with resolved URL: \(update.id)")
|
|
85
87
|
if let newItem = self.createGaplessPlayerItem(for: update, isPreload: false) {
|
|
@@ -131,14 +133,20 @@ extension TrackPlayerCore {
|
|
|
131
133
|
"🔄 No current item — full queue rebuild from currentTrackIndex \(self.currentTrackIndex)")
|
|
132
134
|
self.removeAllItemsCancellingLoads(player)
|
|
133
135
|
var lastItem: AVPlayerItem? = nil
|
|
134
|
-
|
|
136
|
+
// Window like every other rebuild path — materializing the whole remaining
|
|
137
|
+
// playlist builds thousands of AVPlayerItems on playerQueue at once.
|
|
138
|
+
let windowed = self.currentTracks[max(0, self.currentTrackIndex)...]
|
|
139
|
+
.prefix(1 + Constants.queueWindowSize)
|
|
140
|
+
for (offset, track) in windowed.enumerated() {
|
|
135
141
|
let isPreload = offset < Constants.gaplessPreloadCount
|
|
136
142
|
if let newItem = self.createGaplessPlayerItem(for: track, isPreload: isPreload) {
|
|
137
143
|
player.insert(newItem, after: lastItem)
|
|
138
144
|
lastItem = newItem
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
|
-
|
|
147
|
+
if self.intendedToPlay {
|
|
148
|
+
player.rate = Float(self.currentPlaybackSpeed)
|
|
149
|
+
}
|
|
142
150
|
self.preloadUpcomingTracks(from: self.currentTrackIndex + 1)
|
|
143
151
|
} else {
|
|
144
152
|
// A current AVPlayerItem already exists — preserve it and only rebuild upcoming items.
|
|
@@ -150,6 +150,13 @@ final class DownloadDatabase {
|
|
|
150
150
|
|
|
151
151
|
func getDownloadedTrack(trackId: String) -> DownloadedTrack? {
|
|
152
152
|
return queue.sync {
|
|
153
|
+
_getDownloadedTrackUnsafe(trackId: trackId)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Must be called from within `queue` — a nested `queue.sync` here would
|
|
158
|
+
/// deadlock against pending barrier writers on this concurrent queue.
|
|
159
|
+
private func _getDownloadedTrackUnsafe(trackId: String) -> DownloadedTrack? {
|
|
153
160
|
NitroPlayerLogger.log("DownloadDatabase", "🔍 DownloadDatabase.getDownloadedTrack() for trackId: \(trackId)")
|
|
154
161
|
NitroPlayerLogger.log("DownloadDatabase", " Total records in memory: \(downloadedTracks.count)")
|
|
155
162
|
NitroPlayerLogger.log("DownloadDatabase", " Available trackIds: \(Array(downloadedTracks.keys))")
|
|
@@ -175,7 +182,6 @@ final class DownloadDatabase {
|
|
|
175
182
|
|
|
176
183
|
NitroPlayerLogger.log("DownloadDatabase", " ✅ File exists, returning track")
|
|
177
184
|
return recordToDownloadedTrack(record)
|
|
178
|
-
}
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
func getAllDownloadedTracks() -> [DownloadedTrack] {
|
|
@@ -214,7 +220,13 @@ final class DownloadDatabase {
|
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
func getDownloadedPlaylist(playlistId: String) -> DownloadedPlaylist? {
|
|
217
|
-
return queue.sync {
|
|
223
|
+
return queue.sync {
|
|
224
|
+
_getDownloadedPlaylistUnsafe(playlistId: playlistId)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// Must be called from within `queue` — see `_getDownloadedTrackUnsafe`.
|
|
229
|
+
private func _getDownloadedPlaylistUnsafe(playlistId: String) -> DownloadedPlaylist? {
|
|
218
230
|
guard let trackIds = playlistTracks[playlistId], !trackIds.isEmpty else { return nil }
|
|
219
231
|
guard let playlistModel = PlaylistManager.shared.getPlaylist(playlistId: playlistId) else {
|
|
220
232
|
return nil
|
|
@@ -224,7 +236,7 @@ final class DownloadDatabase {
|
|
|
224
236
|
var totalSize: Double = 0
|
|
225
237
|
|
|
226
238
|
for trackId in trackIds {
|
|
227
|
-
if let track =
|
|
239
|
+
if let track = _getDownloadedTrackUnsafe(trackId: trackId) {
|
|
228
240
|
downloadedTracks.append(track)
|
|
229
241
|
totalSize += track.fileSize
|
|
230
242
|
}
|
|
@@ -243,7 +255,6 @@ final class DownloadDatabase {
|
|
|
243
255
|
?? Date().timeIntervalSince1970,
|
|
244
256
|
isComplete: isComplete
|
|
245
257
|
)
|
|
246
|
-
}
|
|
247
258
|
}
|
|
248
259
|
|
|
249
260
|
func getAllDownloadedPlaylists() -> [DownloadedPlaylist] {
|
|
@@ -251,7 +262,7 @@ final class DownloadDatabase {
|
|
|
251
262
|
var playlists: [DownloadedPlaylist] = []
|
|
252
263
|
|
|
253
264
|
for playlistId in playlistTracks.keys {
|
|
254
|
-
if let playlist =
|
|
265
|
+
if let playlist = _getDownloadedPlaylistUnsafe(playlistId: playlistId) {
|
|
255
266
|
playlists.append(playlist)
|
|
256
267
|
}
|
|
257
268
|
}
|
|
@@ -24,33 +24,30 @@ final class DownloadFileManager {
|
|
|
24
24
|
|
|
25
25
|
private let fileManager = FileManager.default
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// `let` computed once in the singleton's init — Swift `lazy var` initialization
|
|
28
|
+
// is not atomic and these were first-touched from multiple queues.
|
|
29
|
+
private let privateDownloadsDirectory: URL
|
|
30
|
+
private let publicDownloadsDirectory: URL
|
|
31
|
+
|
|
32
|
+
// MARK: - Initialization
|
|
33
|
+
|
|
34
|
+
private init() {
|
|
28
35
|
let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
29
|
-
let downloadsPath = documentsPath.appendingPathComponent(Self.privateDownloadsFolderName)
|
|
30
36
|
|
|
37
|
+
let downloadsPath = documentsPath.appendingPathComponent(Self.privateDownloadsFolderName)
|
|
31
38
|
if !fileManager.fileExists(atPath: downloadsPath.path) {
|
|
32
39
|
try? fileManager.createDirectory(at: downloadsPath, withIntermediateDirectories: true)
|
|
33
40
|
}
|
|
41
|
+
privateDownloadsDirectory = downloadsPath
|
|
34
42
|
|
|
35
|
-
return downloadsPath
|
|
36
|
-
}()
|
|
37
|
-
|
|
38
|
-
private lazy var publicDownloadsDirectory: URL = {
|
|
39
43
|
// On iOS, we can't write to a truly public folder, but we can use the shared container
|
|
40
44
|
// or the Documents folder which is accessible via Files app
|
|
41
|
-
let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
42
45
|
let publicPath = documentsPath.appendingPathComponent(Self.publicDownloadsFolderName)
|
|
43
|
-
|
|
44
46
|
if !fileManager.fileExists(atPath: publicPath.path) {
|
|
45
47
|
try? fileManager.createDirectory(at: publicPath, withIntermediateDirectories: true)
|
|
46
48
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}()
|
|
50
|
-
|
|
51
|
-
// MARK: - Initialization
|
|
52
|
-
|
|
53
|
-
private init() {}
|
|
49
|
+
publicDownloadsDirectory = publicPath
|
|
50
|
+
}
|
|
54
51
|
|
|
55
52
|
// MARK: - File Operations
|
|
56
53
|
|
|
@@ -42,7 +42,7 @@ final class DownloadManagerCore: NSObject {
|
|
|
42
42
|
withIdentifier: Self.backgroundSessionIdentifier)
|
|
43
43
|
configuration.isDiscretionary = false
|
|
44
44
|
configuration.sessionSendsLaunchEvents = true
|
|
45
|
-
configuration.allowsCellularAccess = !config.wifiOnlyDownloads
|
|
45
|
+
configuration.allowsCellularAccess = !(config.wifiOnlyDownloads ?? false)
|
|
46
46
|
return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
47
47
|
}()
|
|
48
48
|
|
|
@@ -66,9 +66,11 @@ final class DownloadManagerCore: NSObject {
|
|
|
66
66
|
|
|
67
67
|
// MARK: - Callbacks
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
private var
|
|
69
|
+
// Keyed so HybridDownloadManager.deinit can remove its registrations instead
|
|
70
|
+
// of leaking closures into dead JS runtimes across reloads
|
|
71
|
+
private var progressCallbacks: [(UUID, (DownloadProgress) -> Void)] = []
|
|
72
|
+
private var stateChangeCallbacks: [(UUID, (String, String, DownloadState, DownloadError?) -> Void)] = []
|
|
73
|
+
private var completeCallbacks: [(UUID, (DownloadedTrack) -> Void)] = []
|
|
72
74
|
|
|
73
75
|
// MARK: - Thread Safety
|
|
74
76
|
|
|
@@ -180,8 +182,10 @@ final class DownloadManagerCore: NSObject {
|
|
|
180
182
|
guard let task = self.activeTasks[downloadId] else { return }
|
|
181
183
|
|
|
182
184
|
task.cancel(byProducingResumeData: { resumeData in
|
|
183
|
-
//
|
|
184
|
-
self.
|
|
185
|
+
// Runs on the URLSession delegate queue — hop back for the shared dict
|
|
186
|
+
self.queue.async(flags: .barrier) {
|
|
187
|
+
self.taskMetadata[downloadId]?.resumeData = resumeData
|
|
188
|
+
}
|
|
185
189
|
})
|
|
186
190
|
|
|
187
191
|
self.taskMetadata[downloadId]?.state = .paused
|
|
@@ -264,7 +268,9 @@ final class DownloadManagerCore: NSObject {
|
|
|
264
268
|
private func _pauseDownloadUnsafe(downloadId: String) {
|
|
265
269
|
guard let task = self.activeTasks[downloadId] else { return }
|
|
266
270
|
task.cancel(byProducingResumeData: { resumeData in
|
|
267
|
-
self.
|
|
271
|
+
self.queue.async(flags: .barrier) {
|
|
272
|
+
self.taskMetadata[downloadId]?.resumeData = resumeData
|
|
273
|
+
}
|
|
268
274
|
})
|
|
269
275
|
self.taskMetadata[downloadId]?.state = .paused
|
|
270
276
|
if let trackId = self.taskMetadata[downloadId]?.trackId {
|
|
@@ -505,23 +511,50 @@ final class DownloadManagerCore: NSObject {
|
|
|
505
511
|
|
|
506
512
|
// MARK: - Callbacks
|
|
507
513
|
|
|
508
|
-
|
|
514
|
+
@discardableResult
|
|
515
|
+
func addProgressCallback(_ callback: @escaping (DownloadProgress) -> Void) -> UUID {
|
|
516
|
+
let id = UUID()
|
|
517
|
+
queue.async(flags: .barrier) {
|
|
518
|
+
self.progressCallbacks.append((id, callback))
|
|
519
|
+
}
|
|
520
|
+
return id
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
func removeProgressCallback(id: UUID) {
|
|
509
524
|
queue.async(flags: .barrier) {
|
|
510
|
-
self.progressCallbacks.
|
|
525
|
+
self.progressCallbacks.removeAll { $0.0 == id }
|
|
511
526
|
}
|
|
512
527
|
}
|
|
513
528
|
|
|
529
|
+
@discardableResult
|
|
514
530
|
func addStateChangeCallback(
|
|
515
531
|
_ callback: @escaping (String, String, DownloadState, DownloadError?) -> Void
|
|
516
|
-
) {
|
|
532
|
+
) -> UUID {
|
|
533
|
+
let id = UUID()
|
|
534
|
+
queue.async(flags: .barrier) {
|
|
535
|
+
self.stateChangeCallbacks.append((id, callback))
|
|
536
|
+
}
|
|
537
|
+
return id
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
func removeStateChangeCallback(id: UUID) {
|
|
541
|
+
queue.async(flags: .barrier) {
|
|
542
|
+
self.stateChangeCallbacks.removeAll { $0.0 == id }
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
@discardableResult
|
|
547
|
+
func addCompleteCallback(_ callback: @escaping (DownloadedTrack) -> Void) -> UUID {
|
|
548
|
+
let id = UUID()
|
|
517
549
|
queue.async(flags: .barrier) {
|
|
518
|
-
self.
|
|
550
|
+
self.completeCallbacks.append((id, callback))
|
|
519
551
|
}
|
|
552
|
+
return id
|
|
520
553
|
}
|
|
521
554
|
|
|
522
|
-
func
|
|
555
|
+
func removeCompleteCallback(id: UUID) {
|
|
523
556
|
queue.async(flags: .barrier) {
|
|
524
|
-
self.completeCallbacks.
|
|
557
|
+
self.completeCallbacks.removeAll { $0.0 == id }
|
|
525
558
|
}
|
|
526
559
|
}
|
|
527
560
|
|
|
@@ -726,8 +759,9 @@ final class DownloadManagerCore: NSObject {
|
|
|
726
759
|
}
|
|
727
760
|
|
|
728
761
|
private func notifyProgress(_ progress: DownloadProgress) {
|
|
762
|
+
let callbacks = self.progressCallbacks
|
|
729
763
|
DispatchQueue.main.async {
|
|
730
|
-
for callback in
|
|
764
|
+
for (_, callback) in callbacks {
|
|
731
765
|
callback(progress)
|
|
732
766
|
}
|
|
733
767
|
}
|
|
@@ -736,16 +770,18 @@ final class DownloadManagerCore: NSObject {
|
|
|
736
770
|
private func notifyStateChange(
|
|
737
771
|
downloadId: String, trackId: String, state: DownloadState, error: DownloadError?
|
|
738
772
|
) {
|
|
773
|
+
let callbacks = self.stateChangeCallbacks
|
|
739
774
|
DispatchQueue.main.async {
|
|
740
|
-
for callback in
|
|
775
|
+
for (_, callback) in callbacks {
|
|
741
776
|
callback(downloadId, trackId, state, error)
|
|
742
777
|
}
|
|
743
778
|
}
|
|
744
779
|
}
|
|
745
780
|
|
|
746
781
|
private func notifyComplete(_ downloadedTrack: DownloadedTrack) {
|
|
782
|
+
let callbacks = self.completeCallbacks
|
|
747
783
|
DispatchQueue.main.async {
|
|
748
|
-
for callback in
|
|
784
|
+
for (_, callback) in callbacks {
|
|
749
785
|
callback(downloadedTrack)
|
|
750
786
|
}
|
|
751
787
|
}
|
|
@@ -907,6 +943,14 @@ extension DownloadManagerCore: URLSessionDownloadDelegate {
|
|
|
907
943
|
self.taskMetadata[downloadId]?.totalBytes =
|
|
908
944
|
totalBytesExpectedToWrite > 0 ? Double(totalBytesExpectedToWrite) : nil
|
|
909
945
|
|
|
946
|
+
// ~4 Hz per download — didWriteData fires many times per second per task
|
|
947
|
+
// and every emission is a main-queue hop plus a JS event
|
|
948
|
+
let now = Date().timeIntervalSince1970
|
|
949
|
+
if let last = self.taskMetadata[downloadId]?.lastProgressEmitAt, now - last < 0.25 {
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
self.taskMetadata[downloadId]?.lastProgressEmitAt = now
|
|
953
|
+
|
|
910
954
|
let progress = DownloadProgress(
|
|
911
955
|
trackId: trackId,
|
|
912
956
|
downloadId: downloadId,
|
|
@@ -1010,6 +1054,7 @@ private struct DownloadTaskMetadata {
|
|
|
1010
1054
|
var bytesDownloaded: Double = 0
|
|
1011
1055
|
var totalBytes: Double?
|
|
1012
1056
|
var error: DownloadError?
|
|
1057
|
+
var lastProgressEmitAt: TimeInterval = 0
|
|
1013
1058
|
|
|
1014
1059
|
func toDownloadTask() -> DownloadTask {
|
|
1015
1060
|
let progress = DownloadProgress(
|