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
|
@@ -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
|
|
@@ -16,6 +16,8 @@ class MediaSessionManager {
|
|
|
16
16
|
|
|
17
17
|
private enum Constants {
|
|
18
18
|
static let artworkSize: CGFloat = 500.0
|
|
19
|
+
static let defaultRemoteSkipInterval: Double = 15.0
|
|
20
|
+
static let supportedPlaybackRates: [Float] = [0.5, 1.0, 1.25, 1.5, 1.75, 2.0]
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
// MARK: - Properties
|
|
@@ -24,6 +26,8 @@ class MediaSessionManager {
|
|
|
24
26
|
private let artworkCache = NSCache<NSString, UIImage>()
|
|
25
27
|
|
|
26
28
|
private var showInNotification: Bool = true
|
|
29
|
+
private var remoteSkipForwardInterval: Double = Constants.defaultRemoteSkipInterval
|
|
30
|
+
private var remoteSkipBackwardInterval: Double = Constants.defaultRemoteSkipInterval
|
|
27
31
|
|
|
28
32
|
// Tracks the artwork URL currently shown so we can discard stale async loads
|
|
29
33
|
private var lastArtworkUrl: String?
|
|
@@ -36,6 +40,7 @@ class MediaSessionManager {
|
|
|
36
40
|
// per event; these two Ints are computed on playerQueue instead.
|
|
37
41
|
private var cachedQueueCount: Int = 0
|
|
38
42
|
private var cachedPositionInQueue: Int = -1
|
|
43
|
+
private var cachedPlaybackSpeed: Double = 1.0
|
|
39
44
|
|
|
40
45
|
init() {
|
|
41
46
|
setupRemoteCommandCenter()
|
|
@@ -48,11 +53,24 @@ class MediaSessionManager {
|
|
|
48
53
|
func configure(
|
|
49
54
|
androidAutoEnabled: Bool?,
|
|
50
55
|
carPlayEnabled: Bool?,
|
|
51
|
-
showInNotification: Bool
|
|
56
|
+
showInNotification: Bool?,
|
|
57
|
+
remoteSkipForwardInterval: Double?,
|
|
58
|
+
remoteSkipBackwardInterval: Double?,
|
|
59
|
+
playbackSpeed: Double?
|
|
52
60
|
) {
|
|
53
61
|
if let showInNotification = showInNotification {
|
|
54
62
|
self.showInNotification = showInNotification
|
|
55
63
|
}
|
|
64
|
+
if let playbackSpeed = sanitizedPositive(playbackSpeed) {
|
|
65
|
+
cachedPlaybackSpeed = playbackSpeed
|
|
66
|
+
}
|
|
67
|
+
if let interval = sanitizedPositive(remoteSkipForwardInterval) {
|
|
68
|
+
self.remoteSkipForwardInterval = interval
|
|
69
|
+
}
|
|
70
|
+
if let interval = sanitizedPositive(remoteSkipBackwardInterval) {
|
|
71
|
+
self.remoteSkipBackwardInterval = interval
|
|
72
|
+
}
|
|
73
|
+
updateSkipCommandIntervals()
|
|
56
74
|
refresh()
|
|
57
75
|
}
|
|
58
76
|
|
|
@@ -61,12 +79,14 @@ class MediaSessionManager {
|
|
|
61
79
|
// Receives pre-computed values captured on playerQueue — no player access here.
|
|
62
80
|
|
|
63
81
|
func updateFromPlayerQueue(
|
|
64
|
-
track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int
|
|
82
|
+
track: TrackItem, state: PlayerState, queueCount: Int, positionInQueue: Int,
|
|
83
|
+
playbackSpeed: Double
|
|
65
84
|
) {
|
|
66
85
|
cachedTrack = track
|
|
67
86
|
cachedState = state
|
|
68
87
|
cachedQueueCount = queueCount
|
|
69
88
|
cachedPositionInQueue = positionInQueue
|
|
89
|
+
cachedPlaybackSpeed = sanitizedPositive(playbackSpeed) ?? 1.0
|
|
70
90
|
refreshInternal()
|
|
71
91
|
}
|
|
72
92
|
|
|
@@ -123,6 +143,7 @@ class MediaSessionManager {
|
|
|
123
143
|
let currentPosition = state.currentPosition
|
|
124
144
|
let safePosition = currentPosition.isNaN || currentPosition.isInfinite ? 0 : currentPosition
|
|
125
145
|
let isPlaying = state.currentState == .playing
|
|
146
|
+
let playbackSpeed = sanitizedPositive(cachedPlaybackSpeed) ?? 1.0
|
|
126
147
|
|
|
127
148
|
var nowPlayingInfo: [String: Any] = [
|
|
128
149
|
MPMediaItemPropertyTitle: track.title,
|
|
@@ -130,8 +151,8 @@ class MediaSessionManager {
|
|
|
130
151
|
MPMediaItemPropertyAlbumTitle: track.album,
|
|
131
152
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: safePosition,
|
|
132
153
|
MPMediaItemPropertyPlaybackDuration: effectiveDuration,
|
|
133
|
-
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ?
|
|
134
|
-
MPNowPlayingInfoPropertyDefaultPlaybackRate:
|
|
154
|
+
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? playbackSpeed : 0.0,
|
|
155
|
+
MPNowPlayingInfoPropertyDefaultPlaybackRate: playbackSpeed,
|
|
135
156
|
MPNowPlayingInfoPropertyPlaybackQueueCount: max(1, queueCount),
|
|
136
157
|
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(0, positionInQueue),
|
|
137
158
|
]
|
|
@@ -176,10 +197,17 @@ class MediaSessionManager {
|
|
|
176
197
|
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
|
177
198
|
commandCenter.nextTrackCommand.removeTarget(nil)
|
|
178
199
|
commandCenter.previousTrackCommand.removeTarget(nil)
|
|
200
|
+
commandCenter.skipForwardCommand.removeTarget(nil)
|
|
201
|
+
commandCenter.skipBackwardCommand.removeTarget(nil)
|
|
179
202
|
commandCenter.seekForwardCommand.removeTarget(nil)
|
|
180
203
|
commandCenter.seekBackwardCommand.removeTarget(nil)
|
|
204
|
+
commandCenter.changePlaybackRateCommand.removeTarget(nil)
|
|
181
205
|
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
|
182
206
|
|
|
207
|
+
updateSkipCommandIntervals()
|
|
208
|
+
commandCenter.changePlaybackRateCommand.supportedPlaybackRates =
|
|
209
|
+
Constants.supportedPlaybackRates.map { NSNumber(value: $0) }
|
|
210
|
+
|
|
183
211
|
// Play
|
|
184
212
|
commandCenter.playCommand.isEnabled = true
|
|
185
213
|
commandCenter.playCommand.addTarget { [weak self] _ in
|
|
@@ -221,9 +249,45 @@ class MediaSessionManager {
|
|
|
221
249
|
return .success
|
|
222
250
|
}
|
|
223
251
|
|
|
252
|
+
commandCenter.skipForwardCommand.isEnabled = false
|
|
253
|
+
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
|
254
|
+
guard let self = self, let core = self.trackPlayerCore else { return .commandFailed }
|
|
255
|
+
let interval = (event as? MPSkipIntervalCommandEvent)?.interval ?? self.remoteSkipForwardInterval
|
|
256
|
+
Task { await core.seekBy(offset: interval) }
|
|
257
|
+
return .success
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
commandCenter.skipBackwardCommand.isEnabled = false
|
|
261
|
+
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
|
262
|
+
guard let self = self, let core = self.trackPlayerCore else { return .commandFailed }
|
|
263
|
+
let interval = (event as? MPSkipIntervalCommandEvent)?.interval ?? self.remoteSkipBackwardInterval
|
|
264
|
+
Task { await core.seekBy(offset: -interval) }
|
|
265
|
+
return .success
|
|
266
|
+
}
|
|
267
|
+
|
|
224
268
|
commandCenter.seekForwardCommand.isEnabled = false
|
|
225
269
|
commandCenter.seekBackwardCommand.isEnabled = false
|
|
226
270
|
|
|
271
|
+
commandCenter.changePlaybackRateCommand.isEnabled = false
|
|
272
|
+
commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
|
|
273
|
+
guard let core = self?.trackPlayerCore,
|
|
274
|
+
let rateEvent = event as? MPChangePlaybackRateCommandEvent
|
|
275
|
+
else {
|
|
276
|
+
return .commandFailed
|
|
277
|
+
}
|
|
278
|
+
let rate = Double(rateEvent.playbackRate)
|
|
279
|
+
guard rate.isFinite, rate > 0 else { return .commandFailed }
|
|
280
|
+
Task {
|
|
281
|
+
do {
|
|
282
|
+
try await core.setPlaybackSpeed(rate)
|
|
283
|
+
} catch {
|
|
284
|
+
NitroPlayerLogger.log(
|
|
285
|
+
"MediaSessionManager", "❌ Failed to set remote playback rate: \(error)")
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return .success
|
|
289
|
+
}
|
|
290
|
+
|
|
227
291
|
// Scrubber
|
|
228
292
|
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
|
229
293
|
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
|
@@ -257,6 +321,9 @@ class MediaSessionManager {
|
|
|
257
321
|
|
|
258
322
|
commandCenter.nextTrackCommand.isEnabled = hasCurrentTrack && isNotLast
|
|
259
323
|
commandCenter.previousTrackCommand.isEnabled = hasCurrentTrack
|
|
324
|
+
commandCenter.skipForwardCommand.isEnabled = hasCurrentTrack
|
|
325
|
+
commandCenter.skipBackwardCommand.isEnabled = hasCurrentTrack
|
|
326
|
+
commandCenter.changePlaybackRateCommand.isEnabled = hasCurrentTrack
|
|
260
327
|
commandCenter.changePlaybackPositionCommand.isEnabled = hasCurrentTrack && hasDuration
|
|
261
328
|
}
|
|
262
329
|
|
|
@@ -264,11 +331,29 @@ class MediaSessionManager {
|
|
|
264
331
|
let commandCenter = MPRemoteCommandCenter.shared()
|
|
265
332
|
commandCenter.nextTrackCommand.isEnabled = false
|
|
266
333
|
commandCenter.previousTrackCommand.isEnabled = false
|
|
334
|
+
commandCenter.skipForwardCommand.isEnabled = false
|
|
335
|
+
commandCenter.skipBackwardCommand.isEnabled = false
|
|
336
|
+
commandCenter.changePlaybackRateCommand.isEnabled = false
|
|
267
337
|
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
|
268
338
|
}
|
|
269
339
|
|
|
270
340
|
// MARK: - Helpers
|
|
271
341
|
|
|
342
|
+
private func sanitizedPositive(_ value: Double?) -> Double? {
|
|
343
|
+
guard let value = value, value.isFinite, value > 0 else { return nil }
|
|
344
|
+
return value
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private func updateSkipCommandIntervals() {
|
|
348
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
349
|
+
commandCenter.skipForwardCommand.preferredIntervals = [
|
|
350
|
+
NSNumber(value: remoteSkipForwardInterval)
|
|
351
|
+
]
|
|
352
|
+
commandCenter.skipBackwardCommand.preferredIntervals = [
|
|
353
|
+
NSNumber(value: remoteSkipBackwardInterval)
|
|
354
|
+
]
|
|
355
|
+
}
|
|
356
|
+
|
|
272
357
|
private func clearNowPlayingInfo() {
|
|
273
358
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
274
359
|
lastArtworkUrl = nil
|
|
@@ -14,7 +14,13 @@ class PlaylistManager {
|
|
|
14
14
|
private var listeners: [(String, ([PlaylistModel], QueueOperation?) -> Void)] = []
|
|
15
15
|
private var playlistListeners: [String: [(String, (PlaylistModel, QueueOperation?) -> Void)]] =
|
|
16
16
|
[:]
|
|
17
|
-
|
|
17
|
+
// Backing store must only be touched inside `queue`; every current use of the
|
|
18
|
+
// computed property is outside a queue.sync block, so the accessors can sync.
|
|
19
|
+
private var _currentPlaylistId: String?
|
|
20
|
+
private var currentPlaylistId: String? {
|
|
21
|
+
get { queue.sync { _currentPlaylistId } }
|
|
22
|
+
set { queue.sync { _currentPlaylistId = newValue } }
|
|
23
|
+
}
|
|
18
24
|
private let queue = DispatchQueue(label: "com.margelo.nitro.nitroplayer.playlist")
|
|
19
25
|
private var saveDebounceWorkItem: DispatchWorkItem?
|
|
20
26
|
|
|
@@ -45,15 +51,16 @@ class PlaylistManager {
|
|
|
45
51
|
* Delete a playlist
|
|
46
52
|
*/
|
|
47
53
|
func deletePlaylist(playlistId: String) -> Bool {
|
|
48
|
-
let removed = queue.sync {
|
|
49
|
-
|
|
54
|
+
let removed = queue.sync { () -> Bool in
|
|
55
|
+
guard playlists.removeValue(forKey: playlistId) != nil else { return false }
|
|
56
|
+
if _currentPlaylistId == playlistId {
|
|
57
|
+
_currentPlaylistId = nil
|
|
58
|
+
}
|
|
59
|
+
playlistListeners.removeValue(forKey: playlistId)
|
|
60
|
+
return true
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
if removed {
|
|
53
|
-
if currentPlaylistId == playlistId {
|
|
54
|
-
currentPlaylistId = nil
|
|
55
|
-
}
|
|
56
|
-
playlistListeners.removeValue(forKey: playlistId)
|
|
57
64
|
scheduleSave()
|
|
58
65
|
notifyPlaylistsChanged(.remove)
|
|
59
66
|
return true
|
|
@@ -68,11 +75,9 @@ class PlaylistManager {
|
|
|
68
75
|
func updatePlaylist(
|
|
69
76
|
playlistId: String, name: String? = nil, description: String? = nil, artwork: String? = nil
|
|
70
77
|
) -> Bool {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
queue.sync {
|
|
78
|
+
// Single sync: a separate fetch-then-store loses one of two concurrent edits
|
|
79
|
+
let updated = queue.sync { () -> Bool in
|
|
80
|
+
guard let playlist = playlists[playlistId] else { return false }
|
|
76
81
|
playlists[playlistId] = PlaylistModel(
|
|
77
82
|
id: playlist.id,
|
|
78
83
|
name: name ?? playlist.name,
|
|
@@ -80,7 +85,9 @@ class PlaylistManager {
|
|
|
80
85
|
artwork: artwork ?? playlist.artwork,
|
|
81
86
|
tracks: playlist.tracks
|
|
82
87
|
)
|
|
88
|
+
return true
|
|
83
89
|
}
|
|
90
|
+
guard updated else { return false }
|
|
84
91
|
|
|
85
92
|
scheduleSave()
|
|
86
93
|
notifyPlaylistChanged(playlistId, .update)
|
|
@@ -111,11 +118,8 @@ class PlaylistManager {
|
|
|
111
118
|
* Add a track to a playlist
|
|
112
119
|
*/
|
|
113
120
|
func addTrackToPlaylist(playlistId: String, track: TrackItem, index: Int? = nil) -> Bool {
|
|
114
|
-
|
|
115
|
-
return false
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
queue.sync {
|
|
121
|
+
let added = queue.sync { () -> Bool in
|
|
122
|
+
guard let playlist = playlists[playlistId] else { return false }
|
|
119
123
|
var tracks = playlist.tracks
|
|
120
124
|
if let index = index, index >= 0 && index <= tracks.count {
|
|
121
125
|
tracks.insert(track, at: index)
|
|
@@ -129,7 +133,9 @@ class PlaylistManager {
|
|
|
129
133
|
artwork: playlist.artwork,
|
|
130
134
|
tracks: tracks
|
|
131
135
|
)
|
|
136
|
+
return true
|
|
132
137
|
}
|
|
138
|
+
guard added else { return false }
|
|
133
139
|
|
|
134
140
|
scheduleSave()
|
|
135
141
|
notifyPlaylistChanged(playlistId, .add)
|
|
@@ -146,11 +152,8 @@ class PlaylistManager {
|
|
|
146
152
|
* Add multiple tracks to a playlist at once
|
|
147
153
|
*/
|
|
148
154
|
func addTracksToPlaylist(playlistId: String, tracks: [TrackItem], index: Int? = nil) -> Bool {
|
|
149
|
-
|
|
150
|
-
return false
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
queue.sync {
|
|
155
|
+
let added = queue.sync { () -> Bool in
|
|
156
|
+
guard let playlist = playlists[playlistId] else { return false }
|
|
154
157
|
var currentTracks = playlist.tracks
|
|
155
158
|
if let index = index, index >= 0 && index <= currentTracks.count {
|
|
156
159
|
currentTracks.insert(contentsOf: tracks, at: index)
|
|
@@ -164,7 +167,9 @@ class PlaylistManager {
|
|
|
164
167
|
artwork: playlist.artwork,
|
|
165
168
|
tracks: currentTracks
|
|
166
169
|
)
|
|
170
|
+
return true
|
|
167
171
|
}
|
|
172
|
+
guard added else { return false }
|
|
168
173
|
|
|
169
174
|
scheduleSave()
|
|
170
175
|
notifyPlaylistChanged(playlistId, .add)
|
|
@@ -182,11 +187,8 @@ class PlaylistManager {
|
|
|
182
187
|
* Remove a track from a playlist
|
|
183
188
|
*/
|
|
184
189
|
func removeTrackFromPlaylist(playlistId: String, trackId: String) -> Bool {
|
|
185
|
-
|
|
186
|
-
return false
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let removed = queue.sync {
|
|
190
|
+
let removed = queue.sync { () -> Bool in
|
|
191
|
+
guard let playlist = playlists[playlistId] else { return false }
|
|
190
192
|
var tracks = playlist.tracks
|
|
191
193
|
let initialCount = tracks.count
|
|
192
194
|
tracks.removeAll { $0.id == trackId }
|
|
@@ -222,30 +224,27 @@ class PlaylistManager {
|
|
|
222
224
|
* Reorder a track in a playlist
|
|
223
225
|
*/
|
|
224
226
|
func reorderTrackInPlaylist(playlistId: String, trackId: String, newIndex: Int) -> Bool {
|
|
225
|
-
|
|
226
|
-
return false
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
queue.sync {
|
|
237
|
-
var reorderedTracks = tracks
|
|
238
|
-
let track = reorderedTracks.remove(at: oldIndex)
|
|
239
|
-
reorderedTracks.insert(track, at: newIndex)
|
|
227
|
+
let reordered = queue.sync { () -> Bool in
|
|
228
|
+
guard let playlist = playlists[playlistId] else { return false }
|
|
229
|
+
var tracks = playlist.tracks
|
|
230
|
+
guard let oldIndex = tracks.firstIndex(where: { $0.id == trackId }),
|
|
231
|
+
newIndex >= 0 && newIndex < tracks.count
|
|
232
|
+
else {
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
let track = tracks.remove(at: oldIndex)
|
|
236
|
+
tracks.insert(track, at: newIndex)
|
|
240
237
|
|
|
241
238
|
playlists[playlistId] = PlaylistModel(
|
|
242
239
|
id: playlist.id,
|
|
243
240
|
name: playlist.name,
|
|
244
241
|
description: playlist.description,
|
|
245
242
|
artwork: playlist.artwork,
|
|
246
|
-
tracks:
|
|
243
|
+
tracks: tracks
|
|
247
244
|
)
|
|
245
|
+
return true
|
|
248
246
|
}
|
|
247
|
+
guard reordered else { return false }
|
|
249
248
|
|
|
250
249
|
scheduleSave()
|
|
251
250
|
notifyPlaylistChanged(playlistId, .update)
|
|
@@ -417,9 +416,11 @@ class PlaylistManager {
|
|
|
417
416
|
}
|
|
418
417
|
|
|
419
418
|
private func scheduleSave() {
|
|
420
|
-
saveDebounceWorkItem?.cancel()
|
|
421
419
|
let work = DispatchWorkItem { [weak self] in self?.saveToFile() }
|
|
422
|
-
|
|
420
|
+
queue.sync {
|
|
421
|
+
saveDebounceWorkItem?.cancel()
|
|
422
|
+
saveDebounceWorkItem = work
|
|
423
|
+
}
|
|
423
424
|
// Use global background queue — saveToFile calls queue.sync internally,
|
|
424
425
|
// which would deadlock if scheduled on queue itself.
|
|
425
426
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.3, execute: work)
|
|
@@ -459,7 +460,7 @@ class PlaylistManager {
|
|
|
459
460
|
}
|
|
460
461
|
let wrapper: [String: Any] = [
|
|
461
462
|
"playlists": playlistsData,
|
|
462
|
-
"currentPlaylistId":
|
|
463
|
+
"currentPlaylistId": queue.sync { _currentPlaylistId } as Any,
|
|
463
464
|
]
|
|
464
465
|
let data = try JSONSerialization.data(withJSONObject: wrapper, options: [])
|
|
465
466
|
try NitroPlayerStorage.write(filename: "playlists.json", data: data)
|
|
@@ -22,13 +22,20 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
func deletePlaylist(playlistId: String) throws -> Promise<Void> {
|
|
25
|
-
Promise.async {
|
|
25
|
+
Promise.async {
|
|
26
|
+
guard self.playlistManager.deletePlaylist(playlistId: playlistId) else {
|
|
27
|
+
throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
func updatePlaylist(playlistId: String, name: String?, description: String?, artwork: String?) throws -> Promise<Void> {
|
|
29
33
|
Promise.async {
|
|
30
|
-
|
|
34
|
+
guard self.playlistManager.updatePlaylist(
|
|
31
35
|
playlistId: playlistId, name: name, description: description, artwork: artwork)
|
|
36
|
+
else {
|
|
37
|
+
throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
|
|
38
|
+
}
|
|
32
39
|
self.core.updatePlaylist(playlistId: playlistId)
|
|
33
40
|
}
|
|
34
41
|
}
|
|
@@ -48,29 +55,41 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
|
|
|
48
55
|
|
|
49
56
|
func addTrackToPlaylist(playlistId: String, track: TrackItem, index: Double?) throws -> Promise<Void> {
|
|
50
57
|
Promise.async {
|
|
51
|
-
|
|
58
|
+
guard self.playlistManager.addTrackToPlaylist(playlistId: playlistId, track: track, index: index.map { Int($0) })
|
|
59
|
+
else {
|
|
60
|
+
throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
|
|
61
|
+
}
|
|
52
62
|
self.core.updatePlaylist(playlistId: playlistId)
|
|
53
63
|
}
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
func addTracksToPlaylist(playlistId: String, tracks: [TrackItem], index: Double?) throws -> Promise<Void> {
|
|
57
67
|
Promise.async {
|
|
58
|
-
|
|
68
|
+
guard self.playlistManager.addTracksToPlaylist(playlistId: playlistId, tracks: tracks, index: index.map { Int($0) })
|
|
69
|
+
else {
|
|
70
|
+
throw RuntimeError.error(withMessage: "Playlist not found: \(playlistId)")
|
|
71
|
+
}
|
|
59
72
|
self.core.updatePlaylist(playlistId: playlistId)
|
|
60
73
|
}
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
func removeTrackFromPlaylist(playlistId: String, trackId: String) throws -> Promise<Void> {
|
|
64
77
|
Promise.async {
|
|
65
|
-
|
|
78
|
+
guard self.playlistManager.removeTrackFromPlaylist(playlistId: playlistId, trackId: trackId)
|
|
79
|
+
else {
|
|
80
|
+
throw RuntimeError.error(withMessage: "Track \(trackId) not found in playlist \(playlistId)")
|
|
81
|
+
}
|
|
66
82
|
self.core.updatePlaylist(playlistId: playlistId)
|
|
67
83
|
}
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
func reorderTrackInPlaylist(playlistId: String, trackId: String, newIndex: Double) throws -> Promise<Void> {
|
|
71
87
|
Promise.async {
|
|
72
|
-
|
|
88
|
+
guard self.playlistManager.reorderTrackInPlaylist(
|
|
73
89
|
playlistId: playlistId, trackId: trackId, newIndex: Int(newIndex))
|
|
90
|
+
else {
|
|
91
|
+
throw RuntimeError.error(withMessage: "Invalid reorder for track \(trackId) in playlist \(playlistId)")
|
|
92
|
+
}
|
|
74
93
|
self.core.updatePlaylist(playlistId: playlistId)
|
|
75
94
|
}
|
|
76
95
|
}
|
|
@@ -86,8 +105,10 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
|
|
|
86
105
|
// Update PlaylistManager.currentPlaylistId so getCurrentPlaylistId() returns correctly
|
|
87
106
|
if self.playlistManager.loadPlaylist(playlistId: playlistId, index: startIndex) {
|
|
88
107
|
self.core.loadPlaylistOnQueue(playlistId: playlistId, startIndex: startIndex)
|
|
108
|
+
promise.resolve(withResult: ())
|
|
109
|
+
} else {
|
|
110
|
+
promise.reject(withError: RuntimeError.error(withMessage: "Invalid playlist or index: \(playlistId)"))
|
|
89
111
|
}
|
|
90
|
-
promise.resolve(withResult: ())
|
|
91
112
|
}
|
|
92
113
|
return promise
|
|
93
114
|
}
|
|
@@ -5,6 +5,7 @@ type PlaybackProgressCallback = (position: number, totalDuration: number, isManu
|
|
|
5
5
|
type SeekCallback = (position: number, totalDuration: number) => void;
|
|
6
6
|
type TimedMetadataCallback = (metadata: TimedMetadata) => void;
|
|
7
7
|
type TemporaryQueueCallback = (playNextQueue: TrackItem[], upNextQueue: TrackItem[]) => void;
|
|
8
|
+
type AndroidAutoConnectionCallback = (connected: boolean) => void;
|
|
8
9
|
/**
|
|
9
10
|
* Internal subscription manager that allows multiple hooks to subscribe
|
|
10
11
|
* to a single native callback. This solves the problem where registering
|
|
@@ -18,7 +19,9 @@ declare class CallbackSubscriptionManager {
|
|
|
18
19
|
private timedMetadataSubscribers;
|
|
19
20
|
private temporaryQueueSubscribers;
|
|
20
21
|
private playlistsChangedSubscribers;
|
|
22
|
+
private androidAutoConnectionSubscribers;
|
|
21
23
|
private isPlaylistsChangedRegistered;
|
|
24
|
+
private isAndroidAutoConnectionRegistered;
|
|
22
25
|
private isPlaybackStateRegistered;
|
|
23
26
|
private isTrackChangeRegistered;
|
|
24
27
|
private isPlaybackProgressRegistered;
|
|
@@ -61,6 +64,12 @@ declare class CallbackSubscriptionManager {
|
|
|
61
64
|
private ensurePlaylistsChangedRegistered;
|
|
62
65
|
private ensureTemporaryQueueRegistered;
|
|
63
66
|
private ensureSeekRegistered;
|
|
67
|
+
/**
|
|
68
|
+
* Subscribe to Android Auto connection changes
|
|
69
|
+
* @returns Unsubscribe function
|
|
70
|
+
*/
|
|
71
|
+
subscribeToAndroidAutoConnection(callback: AndroidAutoConnectionCallback): () => void;
|
|
72
|
+
private ensureAndroidAutoConnectionRegistered;
|
|
64
73
|
/**
|
|
65
74
|
* Subscribe to in-stream (timed) metadata events
|
|
66
75
|
* @returns Unsubscribe function
|
|
@@ -12,7 +12,9 @@ class CallbackSubscriptionManager {
|
|
|
12
12
|
timedMetadataSubscribers = new Set();
|
|
13
13
|
temporaryQueueSubscribers = new Set();
|
|
14
14
|
playlistsChangedSubscribers = new Set();
|
|
15
|
+
androidAutoConnectionSubscribers = new Set();
|
|
15
16
|
isPlaylistsChangedRegistered = false;
|
|
17
|
+
isAndroidAutoConnectionRegistered = false;
|
|
16
18
|
isPlaybackStateRegistered = false;
|
|
17
19
|
isTrackChangeRegistered = false;
|
|
18
20
|
isPlaybackProgressRegistered = false;
|
|
@@ -205,6 +207,37 @@ class CallbackSubscriptionManager {
|
|
|
205
207
|
console.error('[CallbackManager] Failed to register seek callback:', error);
|
|
206
208
|
}
|
|
207
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Subscribe to Android Auto connection changes
|
|
212
|
+
* @returns Unsubscribe function
|
|
213
|
+
*/
|
|
214
|
+
subscribeToAndroidAutoConnection(callback) {
|
|
215
|
+
this.androidAutoConnectionSubscribers.add(callback);
|
|
216
|
+
this.ensureAndroidAutoConnectionRegistered();
|
|
217
|
+
return () => {
|
|
218
|
+
this.androidAutoConnectionSubscribers.delete(callback);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
ensureAndroidAutoConnectionRegistered() {
|
|
222
|
+
if (this.isAndroidAutoConnectionRegistered)
|
|
223
|
+
return;
|
|
224
|
+
try {
|
|
225
|
+
TrackPlayer.onAndroidAutoConnectionChange((connected) => {
|
|
226
|
+
this.androidAutoConnectionSubscribers.forEach((subscriber) => {
|
|
227
|
+
try {
|
|
228
|
+
subscriber(connected);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
console.error('[CallbackManager] Error in Android Auto connection subscriber:', error);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
this.isAndroidAutoConnectionRegistered = true;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
console.error('[CallbackManager] Failed to register Android Auto connection callback:', error);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
208
241
|
/**
|
|
209
242
|
* Subscribe to in-stream (timed) metadata events
|
|
210
243
|
* @returns Unsubscribe function
|
|
@@ -61,6 +61,18 @@ export function useActualQueue() {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}, []);
|
|
64
|
+
// Skips involving temp tracks fire track-change and temporary-queue-change
|
|
65
|
+
// together; the gate collapses the burst into one getActualQueue() fetch
|
|
66
|
+
const fetchScheduled = useRef(false);
|
|
67
|
+
const scheduleUpdate = useCallback(() => {
|
|
68
|
+
if (fetchScheduled.current)
|
|
69
|
+
return;
|
|
70
|
+
fetchScheduled.current = true;
|
|
71
|
+
queueMicrotask(() => {
|
|
72
|
+
fetchScheduled.current = false;
|
|
73
|
+
updateQueue();
|
|
74
|
+
});
|
|
75
|
+
}, [updateQueue]);
|
|
64
76
|
const refreshQueue = useCallback(() => {
|
|
65
77
|
if (!isMounted.current)
|
|
66
78
|
return;
|
|
@@ -83,22 +95,22 @@ export function useActualQueue() {
|
|
|
83
95
|
// track changes and temporary-queue edits do.
|
|
84
96
|
useEffect(() => {
|
|
85
97
|
return callbackManager.subscribeToTrackChange(() => {
|
|
86
|
-
|
|
98
|
+
scheduleUpdate();
|
|
87
99
|
});
|
|
88
|
-
}, [
|
|
100
|
+
}, [scheduleUpdate]);
|
|
89
101
|
// Update queue when playNext / upNext change (native pushes the new lists).
|
|
90
102
|
useEffect(() => {
|
|
91
103
|
return callbackManager.subscribeToTemporaryQueueChange(() => {
|
|
92
|
-
|
|
104
|
+
scheduleUpdate();
|
|
93
105
|
});
|
|
94
|
-
}, [
|
|
106
|
+
}, [scheduleUpdate]);
|
|
95
107
|
// Update queue when the playlist itself is edited (track added / removed / reordered).
|
|
96
108
|
// Without this, a playlist mutation while paused would leave the queue stale until the
|
|
97
109
|
// next track change — playback-state changes used to paper over it.
|
|
98
110
|
useEffect(() => {
|
|
99
111
|
return callbackManager.subscribeToPlaylistsChanged(() => {
|
|
100
|
-
|
|
112
|
+
scheduleUpdate();
|
|
101
113
|
});
|
|
102
|
-
}, [
|
|
114
|
+
}, [scheduleUpdate]);
|
|
103
115
|
return { queue, refreshQueue, isLoading };
|
|
104
116
|
}
|