react-native-nitro-player 1.6.0 → 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/connection/AndroidAutoConnectionDetector.kt +22 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +21 -9
- 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 +7 -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 +4 -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 +1 -2
- package/lib/types/PlayerQueue.d.ts +8 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
- 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 +1 -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
|
@@ -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(
|
|
@@ -28,6 +28,27 @@ class EqualizerCore {
|
|
|
28
28
|
|
|
29
29
|
// Current gains storage - internal so TapContext can access
|
|
30
30
|
private(set) var currentGains: [Double] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
|
31
|
+
|
|
32
|
+
// Guards currentGains/gainsDirty between the JS thread (slider writes) and the
|
|
33
|
+
// MTAudioProcessingTap render thread — unsynchronized Swift Array access is a
|
|
34
|
+
// CoW data race (torn coefficients, loud glitches, rare crashes)
|
|
35
|
+
private var gainsLock = os_unfair_lock_s()
|
|
36
|
+
|
|
37
|
+
func withGainsLock<T>(_ body: () -> T) -> T {
|
|
38
|
+
os_unfair_lock_lock(&gainsLock)
|
|
39
|
+
defer { os_unfair_lock_unlock(&gainsLock) }
|
|
40
|
+
return body()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Render-thread accessor: returns a snapshot of the gains and clears the dirty
|
|
44
|
+
/// flag atomically, or nil when nothing changed.
|
|
45
|
+
func consumeDirtyGains() -> [Double]? {
|
|
46
|
+
return withGainsLock {
|
|
47
|
+
guard gainsDirty else { return nil }
|
|
48
|
+
gainsDirty = false
|
|
49
|
+
return currentGains
|
|
50
|
+
}
|
|
51
|
+
}
|
|
31
52
|
private var cachedBands: [EqualizerBand]?
|
|
32
53
|
private var cachedCustomPresets: [EqualizerPreset]?
|
|
33
54
|
|
|
@@ -209,9 +230,11 @@ class EqualizerCore {
|
|
|
209
230
|
guard bandIndex >= 0 && bandIndex < 10 else { return false }
|
|
210
231
|
|
|
211
232
|
let clampedGain = max(-12.0, min(12.0, gainDb))
|
|
212
|
-
|
|
233
|
+
withGainsLock {
|
|
234
|
+
currentGains[bandIndex] = clampedGain
|
|
235
|
+
gainsDirty = true
|
|
236
|
+
}
|
|
213
237
|
cachedBands = nil
|
|
214
|
-
gainsDirty = true
|
|
215
238
|
|
|
216
239
|
currentPresetName = nil
|
|
217
240
|
notifyBandChange(getBands())
|
|
@@ -226,11 +249,13 @@ class EqualizerCore {
|
|
|
226
249
|
func setAllBandGains(_ gains: [Double]) -> Bool {
|
|
227
250
|
guard gains.count == 10 else { return false }
|
|
228
251
|
|
|
229
|
-
|
|
230
|
-
|
|
252
|
+
withGainsLock {
|
|
253
|
+
for i in 0..<10 {
|
|
254
|
+
currentGains[i] = max(-12.0, min(12.0, gains[i]))
|
|
255
|
+
}
|
|
256
|
+
gainsDirty = true
|
|
231
257
|
}
|
|
232
258
|
cachedBands = nil
|
|
233
|
-
gainsDirty = true
|
|
234
259
|
|
|
235
260
|
notifyBandChange(getBands())
|
|
236
261
|
saveBandGains(currentGains)
|
|
@@ -406,7 +431,10 @@ class EqualizerCore {
|
|
|
406
431
|
let gains = try? JSONDecoder().decode([Double].self, from: data),
|
|
407
432
|
gains.count == 10
|
|
408
433
|
{
|
|
409
|
-
|
|
434
|
+
withGainsLock {
|
|
435
|
+
currentGains = gains
|
|
436
|
+
gainsDirty = true
|
|
437
|
+
}
|
|
410
438
|
}
|
|
411
439
|
// else: migration from 5-band or fresh install — start at flat (currentGains already zeroed)
|
|
412
440
|
|
|
@@ -478,9 +506,10 @@ private class TapContext {
|
|
|
478
506
|
}
|
|
479
507
|
|
|
480
508
|
func updateCoefficients() {
|
|
481
|
-
|
|
509
|
+
// Snapshot + dirty-clear happen atomically under the gains lock; nil means
|
|
510
|
+
// another consumer already took this change
|
|
511
|
+
guard let gains = eqCore?.consumeDirtyGains() else { return }
|
|
482
512
|
let frequencies: [Float] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
|
483
|
-
let gains = eqCore.currentGains
|
|
484
513
|
|
|
485
514
|
for i in 0..<10 {
|
|
486
515
|
filterCoeffs[i] = calculatePeakingEQCoefficients(
|
|
@@ -490,7 +519,6 @@ private class TapContext {
|
|
|
490
519
|
sampleRate: Double(sampleRate)
|
|
491
520
|
)
|
|
492
521
|
}
|
|
493
|
-
eqCore.gainsDirty = false
|
|
494
522
|
}
|
|
495
523
|
|
|
496
524
|
/// Calculate biquad coefficients for a peaking EQ filter
|