react-native-nitro-player 1.5.0 → 1.6.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/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
- package/ios/HybridTrackPlayer.swift +58 -29
- package/ios/core/TrackPlayerCore.swift +22 -1
- package/ios/core/TrackPlayerListener.swift +84 -5
- package/ios/core/TrackPlayerNotify.swift +13 -7
- package/ios/core/TrackPlayerPlayback.swift +75 -39
- package/ios/core/TrackPlayerQueue.swift +28 -4
- package/ios/core/TrackPlayerQueueBuild.swift +100 -40
- package/ios/core/TrackPlayerRecovery.swift +1 -1
- package/ios/core/TrackPlayerTempQueue.swift +129 -122
- package/ios/core/TrackPlayerUrlLoader.swift +34 -10
- package/ios/download/DownloadDatabase.swift +41 -13
- package/ios/media/MediaSessionManager.swift +19 -12
- package/ios/queue/HybridPlayerQueue.swift +10 -5
- package/lib/hooks/callbackManager.d.ts +27 -1
- package/lib/hooks/callbackManager.js +100 -1
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useActualQueue.js +18 -12
- package/lib/hooks/useNowPlaying.js +10 -2
- package/lib/hooks/useTimedMetadata.d.ts +16 -0
- package/lib/hooks/useTimedMetadata.js +21 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
- package/lib/types/PlayerQueue.d.ts +7 -0
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.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/TimedMetadata.kt +47 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
- package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.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/TimedMetadata.swift +96 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
- package/package.json +1 -1
- package/src/components/CastButton.tsx +14 -2
- package/src/hooks/callbackManager.ts +134 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useActualQueue.ts +18 -13
- package/src/hooks/useNowPlaying.ts +12 -2
- package/src/hooks/useTimedMetadata.ts +25 -0
- package/src/specs/TrackPlayer.nitro.ts +6 -0
- package/src/types/PlayerQueue.ts +8 -0
|
@@ -18,11 +18,13 @@ extension TrackPlayerCore {
|
|
|
18
18
|
await withPlayerQueueNoThrow { self.getActualQueueInternal() }
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
@discardableResult
|
|
22
|
+
func skipToIndexOnQueue(index: Int) -> Bool {
|
|
23
|
+
isCasting ? skipToIndexCastInternal(index: index) : skipToIndexInternal(index: index)
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
func skipToIndex(index: Int) async -> Bool {
|
|
22
|
-
|
|
23
|
-
return await withPlayerQueueNoThrow { self.skipToIndexCastInternal(index: index) }
|
|
24
|
-
}
|
|
25
|
-
return await withPlayerQueueNoThrow { self.skipToIndexInternal(index: index) }
|
|
27
|
+
await withPlayerQueueNoThrow { self.skipToIndexOnQueue(index: index) }
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
func playFromIndex(index: Int) async {
|
|
@@ -144,6 +146,28 @@ extension TrackPlayerCore {
|
|
|
144
146
|
return queue
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/// Length of the logical queue and the current track's position in it, derived
|
|
150
|
+
/// arithmetically. `getActualQueueInternal()` copies every TrackItem to answer the
|
|
151
|
+
/// same two questions — too expensive to run on every playback-state change.
|
|
152
|
+
func actualQueueMetrics() -> (count: Int, position: Int) {
|
|
153
|
+
let before: Int
|
|
154
|
+
if currentTemporaryType != .none {
|
|
155
|
+
before = min(currentTrackIndex + 1, currentTracks.count)
|
|
156
|
+
} else {
|
|
157
|
+
before = max(0, currentTrackIndex)
|
|
158
|
+
}
|
|
159
|
+
let hasCurrent = getCurrentTrack() != nil
|
|
160
|
+
let playNextCount =
|
|
161
|
+
currentTemporaryType == .playNext ? max(0, playNextStack.count - 1) : playNextStack.count
|
|
162
|
+
let upNextCount =
|
|
163
|
+
currentTemporaryType == .upNext ? max(0, upNextQueue.count - 1) : upNextQueue.count
|
|
164
|
+
let after =
|
|
165
|
+
currentTrackIndex + 1 < currentTracks.count ? currentTracks.count - (currentTrackIndex + 1) : 0
|
|
166
|
+
|
|
167
|
+
let count = before + (hasCurrent ? 1 : 0) + playNextCount + upNextCount + after
|
|
168
|
+
return (count, hasCurrent ? before : -1)
|
|
169
|
+
}
|
|
170
|
+
|
|
147
171
|
func getCurrentTrack() -> TrackItem? {
|
|
148
172
|
if currentTemporaryType != .none, let trackId = activeCurrentTrackId {
|
|
149
173
|
if currentTemporaryType == .playNext { return playNextStack.first(where: { $0.id == trackId }) }
|
|
@@ -11,6 +11,34 @@ import Foundation
|
|
|
11
11
|
|
|
12
12
|
extension TrackPlayerCore {
|
|
13
13
|
|
|
14
|
+
/// Cancels an item's outstanding asset loading before it is released.
|
|
15
|
+
///
|
|
16
|
+
/// `AVURLAsset.dealloc` blocks the deallocating thread inside `URLAssetFinalize`
|
|
17
|
+
/// until any in-flight `loadValuesAsynchronously` / resource-loader work finishes —
|
|
18
|
+
/// and AVPlayer tears removed items down on the MAIN thread. Dropping items without
|
|
19
|
+
/// cancelling first therefore stalls the main run loop (and with it RCTTiming, so
|
|
20
|
+
/// JS timers stop firing) for as long as the pending loads take.
|
|
21
|
+
/// `cancelLoading()` itself waits for in-flight work to unwind, so it is only worth
|
|
22
|
+
/// paying on a full teardown (playlist switch / jump), never on the incremental
|
|
23
|
+
/// window rebuild that runs on every playNext / addToUpNext.
|
|
24
|
+
///
|
|
25
|
+
/// A cancelled asset must never be handed back out: `createGaplessPlayerItem` reuses
|
|
26
|
+
/// `preloadedAssets`, and reusing a cancelled one yields an item that fails and then
|
|
27
|
+
/// burns the failed-item retry budget. Evict it instead.
|
|
28
|
+
func cancelLoading(of item: AVPlayerItem) {
|
|
29
|
+
guard let asset = item.asset as? AVURLAsset else { return }
|
|
30
|
+
if let trackId = item.trackId, preloadedAssets[trackId] === asset {
|
|
31
|
+
preloadedAssets.removeValue(forKey: trackId)
|
|
32
|
+
}
|
|
33
|
+
asset.cancelLoading()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Removes every item from the player, cancelling their asset loads first.
|
|
37
|
+
func removeAllItemsCancellingLoads(_ player: AVQueuePlayer) {
|
|
38
|
+
for item in player.items() { cancelLoading(of: item) }
|
|
39
|
+
player.removeAllItems()
|
|
40
|
+
}
|
|
41
|
+
|
|
14
42
|
func updatePlayerQueue(tracks: [TrackItem]) {
|
|
15
43
|
// While casting, mirror the new queue to the Cast device instead of building
|
|
16
44
|
// the local AVQueuePlayer (which would start local audio).
|
|
@@ -55,6 +83,7 @@ extension TrackPlayerCore {
|
|
|
55
83
|
player?.automaticallyWaitsToMinimizeStalling = true
|
|
56
84
|
|
|
57
85
|
// Clear old preloaded assets when loading new queue
|
|
86
|
+
for asset in preloadedAssets.values { asset.cancelLoading() }
|
|
58
87
|
preloadedAssets.removeAll()
|
|
59
88
|
|
|
60
89
|
guard let existingPlayer = self.player else {
|
|
@@ -63,7 +92,7 @@ extension TrackPlayerCore {
|
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
NitroPlayerLogger.log("TrackPlayerCore", "🔄 Removing \(existingPlayer.items().count) old items from player")
|
|
66
|
-
existingPlayer
|
|
95
|
+
removeAllItemsCancellingLoads(existingPlayer)
|
|
67
96
|
|
|
68
97
|
// Lazy-load mode if the first track needs a URL. Tracks further in the queue with
|
|
69
98
|
// empty URLs are dropped safely by compactMap below and resolved via onTracksNeedUpdate.
|
|
@@ -75,7 +104,9 @@ extension TrackPlayerCore {
|
|
|
75
104
|
return
|
|
76
105
|
}
|
|
77
106
|
|
|
78
|
-
|
|
107
|
+
// Only materialize a window of items — the rest are added as playback advances.
|
|
108
|
+
let windowed = tracks.prefix(1 + Constants.queueWindowSize)
|
|
109
|
+
let items = windowed.enumerated().compactMap { (index, track) -> AVPlayerItem? in
|
|
79
110
|
let isPreload = index < Constants.gaplessPreloadCount
|
|
80
111
|
return createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
81
112
|
}
|
|
@@ -182,7 +213,7 @@ extension TrackPlayerCore {
|
|
|
182
213
|
player?.removeTimeObserver(boundaryObserver)
|
|
183
214
|
self.boundaryTimeObserver = nil
|
|
184
215
|
}
|
|
185
|
-
player
|
|
216
|
+
if let p = player { removeAllItemsCancellingLoads(p) }
|
|
186
217
|
self.currentTracks = fullPlaylist
|
|
187
218
|
if let track = self.currentTracks[safe: index] {
|
|
188
219
|
notifyTrackChange(track, .skip)
|
|
@@ -190,7 +221,8 @@ extension TrackPlayerCore {
|
|
|
190
221
|
return true
|
|
191
222
|
}
|
|
192
223
|
|
|
193
|
-
let
|
|
224
|
+
let windowEnd = min(index + 1 + Constants.queueWindowSize, fullPlaylist.count)
|
|
225
|
+
let tracksToPlay = Array(fullPlaylist[index..<windowEnd])
|
|
194
226
|
NitroPlayerLogger.log("TrackPlayerCore", " 🔄 Creating gapless queue with \(tracksToPlay.count) tracks starting from index \(index)")
|
|
195
227
|
|
|
196
228
|
let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
|
|
@@ -212,7 +244,7 @@ extension TrackPlayerCore {
|
|
|
212
244
|
// Re-enable stall waiting for the new first track
|
|
213
245
|
player.automaticallyWaitsToMinimizeStalling = true
|
|
214
246
|
|
|
215
|
-
player
|
|
247
|
+
removeAllItemsCancellingLoads(player)
|
|
216
248
|
var lastItem: AVPlayerItem? = nil
|
|
217
249
|
for item in items {
|
|
218
250
|
player.insert(item, after: lastItem)
|
|
@@ -308,6 +340,11 @@ extension TrackPlayerCore {
|
|
|
308
340
|
newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
|
|
309
341
|
}
|
|
310
342
|
|
|
343
|
+
// Window the materialized queue — currentItemDidChange tops it up on every transition.
|
|
344
|
+
if newQueueTracks.count > Constants.queueWindowSize {
|
|
345
|
+
newQueueTracks.removeSubrange(Constants.queueWindowSize...)
|
|
346
|
+
}
|
|
347
|
+
|
|
311
348
|
// Collect existing upcoming AVPlayerItems
|
|
312
349
|
let upcomingItems: [AVPlayerItem]
|
|
313
350
|
if let ci = currentItem, let ciIndex = playingItems.firstIndex(of: ci) {
|
|
@@ -370,19 +407,47 @@ extension TrackPlayerCore {
|
|
|
370
407
|
return
|
|
371
408
|
}
|
|
372
409
|
|
|
373
|
-
//
|
|
374
|
-
|
|
410
|
+
// Incremental path (no changedTrackIds — skip, reorder, window top-up).
|
|
411
|
+
// Keep the longest matching prefix of already-buffered items and only rebuild
|
|
412
|
+
// from the first divergence. A pure append (the common window top-up) therefore
|
|
413
|
+
// touches nothing that is already buffered, preserving gapless transitions.
|
|
414
|
+
var commonPrefix = 0
|
|
415
|
+
while commonPrefix < upcomingItems.count,
|
|
416
|
+
commonPrefix < newQueueTracks.count,
|
|
417
|
+
upcomingItems[commonPrefix].trackId == newQueueTracks[commonPrefix].id
|
|
418
|
+
{
|
|
419
|
+
commonPrefix += 1
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for item in upcomingItems[commonPrefix...] {
|
|
375
423
|
player.remove(item)
|
|
376
424
|
}
|
|
377
425
|
|
|
378
|
-
var lastItem = currentItem
|
|
379
|
-
for
|
|
426
|
+
var lastItem: AVPlayerItem? = commonPrefix > 0 ? upcomingItems[commonPrefix - 1] : currentItem
|
|
427
|
+
for offset in commonPrefix..<newQueueTracks.count {
|
|
380
428
|
let isPreload = offset < Constants.gaplessPreloadCount
|
|
381
|
-
if let item = createGaplessPlayerItem(for:
|
|
429
|
+
if let item = createGaplessPlayerItem(for: newQueueTracks[offset], isPreload: isPreload) {
|
|
382
430
|
player.insert(item, after: lastItem)
|
|
383
431
|
lastItem = item
|
|
384
432
|
}
|
|
385
433
|
}
|
|
434
|
+
|
|
435
|
+
let created = newQueueTracks.count - commonPrefix
|
|
436
|
+
NitroPlayerLogger.log(
|
|
437
|
+
"TrackPlayerCore",
|
|
438
|
+
"🔄 Incremental rebuild: kept \(commonPrefix) buffered items, created \(created)")
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/// True when the logical queue has a track after the current one, regardless of
|
|
442
|
+
/// how much of it is currently materialized into the AVQueuePlayer.
|
|
443
|
+
func hasUpcomingTrack() -> Bool {
|
|
444
|
+
let pendingPlayNext =
|
|
445
|
+
currentTemporaryType == .playNext ? max(0, playNextStack.count - 1) : playNextStack.count
|
|
446
|
+
if pendingPlayNext > 0 { return true }
|
|
447
|
+
let pendingUpNext =
|
|
448
|
+
currentTemporaryType == .upNext ? max(0, upNextQueue.count - 1) : upNextQueue.count
|
|
449
|
+
if pendingUpNext > 0 { return true }
|
|
450
|
+
return currentTrackIndex + 1 < currentTracks.count
|
|
386
451
|
}
|
|
387
452
|
|
|
388
453
|
/// Extracts custom HTTP headers (e.g. `Authorization`) from `extraPayload.headers`.
|
|
@@ -479,46 +544,40 @@ extension TrackPlayerCore {
|
|
|
479
544
|
item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
|
|
480
545
|
item.trackId = track.id
|
|
481
546
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
// "tracks" key is now loaded — EQ tap attaches synchronously
|
|
497
|
-
EqualizerCore.shared.applyAudioMix(to: item)
|
|
498
|
-
}
|
|
499
|
-
} else {
|
|
500
|
-
EqualizerCore.shared.applyAudioMix(to: item)
|
|
501
|
-
}
|
|
547
|
+
// Deliberately NOT calling loadValuesAsynchronously here.
|
|
548
|
+
//
|
|
549
|
+
// AVURLAsset.dealloc blocks its thread inside URLAssetFinalize until any in-flight
|
|
550
|
+
// key load finishes — and AVPlayer tears removed items down on the MAIN thread, so
|
|
551
|
+
// an item discarded mid-load stalls the main run loop (and RCTTiming with it, which
|
|
552
|
+
// stops JS timers). Asset warm-up still happens in preloadUpcomingTracks, which only
|
|
553
|
+
// publishes assets into `preloadedAssets` once every key has finished loading, so a
|
|
554
|
+
// reused preloaded asset has nothing in flight either.
|
|
555
|
+
//
|
|
556
|
+
// Caveat: with the equalizer ENABLED, applyAudioMix still loads the "tracks" key
|
|
557
|
+
// asynchronously for assets that don't have it yet, so a small window remains. That
|
|
558
|
+
// is unchanged from before (it was already the non-preload path) and is strictly
|
|
559
|
+
// less in-flight work than the previous 4-key preload on every queued item.
|
|
560
|
+
EqualizerCore.shared.applyAudioMix(to: item)
|
|
502
561
|
|
|
503
562
|
return item
|
|
504
563
|
}
|
|
505
564
|
|
|
506
565
|
/// Preloads assets for upcoming tracks to enable gapless playback
|
|
507
566
|
func preloadUpcomingTracks(from startIndex: Int) {
|
|
508
|
-
//
|
|
567
|
+
// Snapshot every piece of playerQueue-owned state HERE (we are on playerQueue).
|
|
568
|
+
// `preloadQueue` must never touch currentTracks / preloadedAssets directly —
|
|
569
|
+
// Swift Array/Dictionary are not safe for concurrent access.
|
|
509
570
|
let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
|
|
571
|
+
let alreadyPreloaded = Set(preloadedAssets.keys)
|
|
572
|
+
let endIndex = min(startIndex + Constants.gaplessPreloadCount, currentTracks.count)
|
|
573
|
+
guard startIndex >= 0, startIndex < endIndex else { return }
|
|
574
|
+
let candidates = Array(currentTracks[startIndex..<endIndex])
|
|
510
575
|
|
|
511
576
|
preloadQueue.async { [weak self] in
|
|
512
577
|
guard let self else { return }
|
|
513
578
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
for i in startIndex..<endIndex {
|
|
518
|
-
guard i < tracks.count else { break }
|
|
519
|
-
let track = tracks[i]
|
|
520
|
-
|
|
521
|
-
if self.preloadedAssets[track.id] != nil || queuedTrackIds.contains(track.id) {
|
|
579
|
+
for track in candidates {
|
|
580
|
+
if alreadyPreloaded.contains(track.id) || queuedTrackIds.contains(track.id) {
|
|
522
581
|
continue
|
|
523
582
|
}
|
|
524
583
|
|
|
@@ -567,7 +626,8 @@ extension TrackPlayerCore {
|
|
|
567
626
|
|
|
568
627
|
let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
|
|
569
628
|
for id in assetsToRemove {
|
|
570
|
-
|
|
629
|
+
// Cancel before dropping the last reference — see cancelLoading(of:).
|
|
630
|
+
self.preloadedAssets.removeValue(forKey: id)?.cancelLoading()
|
|
571
631
|
}
|
|
572
632
|
|
|
573
633
|
if !assetsToRemove.isEmpty {
|
|
@@ -64,7 +64,7 @@ extension TrackPlayerCore {
|
|
|
64
64
|
notifyTracksNeedUpdate(tracks: [track], lookahead: lookaheadCount)
|
|
65
65
|
|
|
66
66
|
// A preloaded asset for this track shares the same dead connection state.
|
|
67
|
-
preloadedAssets.removeValue(forKey: trackId)
|
|
67
|
+
preloadedAssets.removeValue(forKey: trackId)?.cancelLoading()
|
|
68
68
|
// Surface a buffering state while the retry is pending.
|
|
69
69
|
isRecoveringFromStall = true
|
|
70
70
|
emitStateChange()
|
|
@@ -9,159 +9,166 @@ import Foundation
|
|
|
9
9
|
extension TrackPlayerCore {
|
|
10
10
|
|
|
11
11
|
func loadPlaylist(playlistId: String, startIndex: Int? = nil) async {
|
|
12
|
-
await withPlayerQueueNoThrow {
|
|
13
|
-
|
|
14
|
-
self.upNextQueue.removeAll()
|
|
15
|
-
self.currentTemporaryType = .none
|
|
16
|
-
|
|
17
|
-
NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "🎼", count: Constants.playlistSeparatorLength))
|
|
18
|
-
NitroPlayerLogger.log("TrackPlayerCore", "📂 LOAD PLAYLIST REQUEST")
|
|
19
|
-
NitroPlayerLogger.log("TrackPlayerCore", " Playlist ID: \(playlistId)")
|
|
20
|
-
NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
|
|
21
|
-
|
|
22
|
-
guard let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else {
|
|
23
|
-
NitroPlayerLogger.log("TrackPlayerCore", " ❌ Playlist NOT FOUND")
|
|
24
|
-
NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
|
|
25
|
-
return
|
|
26
|
-
}
|
|
12
|
+
await withPlayerQueueNoThrow { self.loadPlaylistOnQueue(playlistId: playlistId, startIndex: startIndex) }
|
|
13
|
+
}
|
|
27
14
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
15
|
+
func loadPlaylistOnQueue(playlistId: String, startIndex: Int? = nil) {
|
|
16
|
+
self.playNextStack.removeAll()
|
|
17
|
+
self.upNextQueue.removeAll()
|
|
18
|
+
self.currentTemporaryType = .none
|
|
19
|
+
|
|
20
|
+
NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "🎼", count: Constants.playlistSeparatorLength))
|
|
21
|
+
NitroPlayerLogger.log("TrackPlayerCore", "📂 LOAD PLAYLIST REQUEST")
|
|
22
|
+
NitroPlayerLogger.log("TrackPlayerCore", " Playlist ID: \(playlistId)")
|
|
23
|
+
NitroPlayerLogger.log("TrackPlayerCore", " 🧹 Cleared temporary tracks")
|
|
24
|
+
|
|
25
|
+
guard let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else {
|
|
26
|
+
NitroPlayerLogger.log("TrackPlayerCore", " ❌ Playlist NOT FOUND")
|
|
33
27
|
NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
|
|
28
|
+
return
|
|
29
|
+
}
|
|
34
30
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
targetIndex = startIndex
|
|
42
|
-
} else {
|
|
43
|
-
targetIndex = 0
|
|
44
|
-
}
|
|
31
|
+
NitroPlayerLogger.log("TrackPlayerCore", " ✅ Found playlist: \(playlist.name)")
|
|
32
|
+
NitroPlayerLogger.log("TrackPlayerCore", " 📋 Contains \(playlist.tracks.count) tracks:")
|
|
33
|
+
for (index, track) in playlist.tracks.enumerated() {
|
|
34
|
+
NitroPlayerLogger.log("TrackPlayerCore", " [\(index + 1)] \(track.title) - \(track.artist)")
|
|
35
|
+
}
|
|
36
|
+
NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// Set currentTracks directly so rebuildQueueFromPlaylistIndex can use them.
|
|
52
|
-
self.currentTracks = playlist.tracks
|
|
53
|
-
self.preloadedAssets.removeAll()
|
|
54
|
-
_ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
|
|
38
|
+
let targetIndex: Int
|
|
39
|
+
if let startIndex {
|
|
40
|
+
guard startIndex >= 0 && startIndex < playlist.tracks.count else {
|
|
41
|
+
NitroPlayerLogger.log("TrackPlayerCore", " ❌ Invalid start index: \(startIndex) (track count: \(playlist.tracks.count))")
|
|
42
|
+
return
|
|
55
43
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
44
|
+
targetIndex = startIndex
|
|
45
|
+
} else {
|
|
46
|
+
targetIndex = 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
self.currentPlaylistId = playlistId
|
|
50
|
+
if targetIndex == 0 {
|
|
51
|
+
self.updatePlayerQueue(tracks: playlist.tracks)
|
|
52
|
+
} else {
|
|
53
|
+
// Bypass updatePlayerQueue to avoid emitting a spurious onTrackChange for index 0.
|
|
54
|
+
// Set currentTracks directly so rebuildQueueFromPlaylistIndex can use them.
|
|
55
|
+
self.currentTracks = playlist.tracks
|
|
56
|
+
for asset in self.preloadedAssets.values { asset.cancelLoading() }
|
|
57
|
+
self.preloadedAssets.removeAll()
|
|
58
|
+
_ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
|
|
59
59
|
}
|
|
60
|
+
self.emitStateChange()
|
|
61
|
+
self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
|
|
62
|
+
self.notifyTemporaryQueueChange()
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/// Callable from any thread (PlaylistManager runs on its own queue) — all state
|
|
66
|
+
/// access happens on playerQueue.
|
|
62
67
|
func updatePlaylist(playlistId: String) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
playerQueue.async { [weak self] in
|
|
69
|
+
guard let self, self.currentPlaylistId == playlistId else { return }
|
|
70
|
+
|
|
71
|
+
self.playlistUpdateGeneration &+= 1
|
|
72
|
+
let generation = self.playlistUpdateGeneration
|
|
73
|
+
|
|
74
|
+
// Run at the tail of the current burst; stale generations drop out.
|
|
75
|
+
self.playerQueue.async { [weak self] in
|
|
76
|
+
guard let self, generation == self.playlistUpdateGeneration,
|
|
77
|
+
self.currentPlaylistId == playlistId,
|
|
78
|
+
let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else { return }
|
|
79
|
+
|
|
80
|
+
// If nothing is playing yet, do a full load
|
|
81
|
+
guard self.player?.currentItem != nil else {
|
|
82
|
+
self.updatePlayerQueue(tracks: playlist.tracks)
|
|
83
|
+
self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
71
86
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
self.
|
|
87
|
+
// Update tracks list without interrupting playback
|
|
88
|
+
self.currentTracks = playlist.tracks
|
|
89
|
+
self.rebuildAVQueueFromCurrentPosition()
|
|
75
90
|
self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
|
|
76
|
-
return
|
|
77
91
|
}
|
|
78
|
-
|
|
79
|
-
// Update tracks list without interrupting playback
|
|
80
|
-
self.currentTracks = playlist.tracks
|
|
81
|
-
self.rebuildAVQueueFromCurrentPosition()
|
|
82
|
-
self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
|
|
83
92
|
}
|
|
84
|
-
|
|
85
|
-
pendingPlaylistUpdateWorkItem = workItem
|
|
86
|
-
playerQueue.async(execute: workItem)
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
func
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
|
|
93
|
-
}
|
|
94
|
-
NitroPlayerLogger.log("TrackPlayerCore", "⏭️ playNext(\(trackId))")
|
|
95
|
-
self.playNextStack.insert(track, at: 0)
|
|
96
|
-
NitroPlayerLogger.log("TrackPlayerCore", " ✅ Added '\(track.title)' to playNext stack (position: 1)")
|
|
97
|
-
if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
|
|
98
|
-
self.notifyTemporaryQueueChange()
|
|
95
|
+
func playNextOnQueue(trackId: String) throws {
|
|
96
|
+
guard let track = findTrackById(trackId) else {
|
|
97
|
+
throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
|
|
99
98
|
}
|
|
99
|
+
NitroPlayerLogger.log("TrackPlayerCore", "⏭️ playNext(\(trackId))")
|
|
100
|
+
playNextStack.insert(track, at: 0)
|
|
101
|
+
if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
|
|
102
|
+
notifyTemporaryQueueChange()
|
|
103
|
+
}
|
|
104
|
+
func playNext(trackId: String) async throws {
|
|
105
|
+
try await withPlayerQueue { try self.playNextOnQueue(trackId: trackId) }
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
func
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
|
|
106
|
-
}
|
|
107
|
-
NitroPlayerLogger.log("TrackPlayerCore", "📋 addToUpNext(\(trackId))")
|
|
108
|
-
self.upNextQueue.append(track)
|
|
109
|
-
NitroPlayerLogger.log("TrackPlayerCore", " ✅ Added '\(track.title)' to upNext queue (position: \(self.upNextQueue.count))")
|
|
110
|
-
if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
|
|
111
|
-
self.notifyTemporaryQueueChange()
|
|
108
|
+
func addToUpNextOnQueue(trackId: String) throws {
|
|
109
|
+
guard let track = findTrackById(trackId) else {
|
|
110
|
+
throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
|
|
112
111
|
}
|
|
112
|
+
NitroPlayerLogger.log("TrackPlayerCore", "📋 addToUpNext(\(trackId))")
|
|
113
|
+
upNextQueue.append(track)
|
|
114
|
+
if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
|
|
115
|
+
notifyTemporaryQueueChange()
|
|
116
|
+
}
|
|
117
|
+
func addToUpNext(trackId: String) async throws {
|
|
118
|
+
try await withPlayerQueue { try self.addToUpNextOnQueue(trackId: trackId) }
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
func removeFromPlayNextOnQueue(trackId: String) -> Bool {
|
|
122
|
+
guard let idx = playNextStack.firstIndex(where: { $0.id == trackId }) else { return false }
|
|
123
|
+
playNextStack.remove(at: idx)
|
|
124
|
+
if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
|
|
125
|
+
notifyTemporaryQueueChange()
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
115
128
|
func removeFromPlayNext(trackId: String) async -> Bool {
|
|
116
|
-
await withPlayerQueueNoThrow {
|
|
117
|
-
guard let idx = self.playNextStack.firstIndex(where: { $0.id == trackId }) else { return false }
|
|
118
|
-
self.playNextStack.remove(at: idx)
|
|
119
|
-
if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
|
|
120
|
-
self.notifyTemporaryQueueChange()
|
|
121
|
-
return true
|
|
122
|
-
}
|
|
129
|
+
await withPlayerQueueNoThrow { self.removeFromPlayNextOnQueue(trackId: trackId) }
|
|
123
130
|
}
|
|
124
131
|
|
|
132
|
+
func removeFromUpNextOnQueue(trackId: String) -> Bool {
|
|
133
|
+
guard let idx = upNextQueue.firstIndex(where: { $0.id == trackId }) else { return false }
|
|
134
|
+
upNextQueue.remove(at: idx)
|
|
135
|
+
if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
|
|
136
|
+
notifyTemporaryQueueChange()
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
125
139
|
func removeFromUpNext(trackId: String) async -> Bool {
|
|
126
|
-
await withPlayerQueueNoThrow {
|
|
127
|
-
guard let idx = self.upNextQueue.firstIndex(where: { $0.id == trackId }) else { return false }
|
|
128
|
-
self.upNextQueue.remove(at: idx)
|
|
129
|
-
if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
|
|
130
|
-
self.notifyTemporaryQueueChange()
|
|
131
|
-
return true
|
|
132
|
-
}
|
|
140
|
+
await withPlayerQueueNoThrow { self.removeFromUpNextOnQueue(trackId: trackId) }
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
func
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
self.notifyTemporaryQueueChange()
|
|
140
|
-
}
|
|
143
|
+
func clearPlayNextOnQueue() {
|
|
144
|
+
playNextStack.removeAll()
|
|
145
|
+
if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
|
|
146
|
+
notifyTemporaryQueueChange()
|
|
141
147
|
}
|
|
148
|
+
func clearPlayNext() async { await withPlayerQueueNoThrow { self.clearPlayNextOnQueue() } }
|
|
142
149
|
|
|
143
|
-
func
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
func clearUpNextOnQueue() {
|
|
151
|
+
upNextQueue.removeAll()
|
|
152
|
+
if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
|
|
153
|
+
notifyTemporaryQueueChange()
|
|
154
|
+
}
|
|
155
|
+
func clearUpNext() async { await withPlayerQueueNoThrow { self.clearUpNextOnQueue() } }
|
|
156
|
+
|
|
157
|
+
func reorderTemporaryTrackOnQueue(trackId: String, newIndex: Int) -> Bool {
|
|
158
|
+
var combined = playNextStack + upNextQueue
|
|
159
|
+
guard let fromIdx = combined.firstIndex(where: { $0.id == trackId }) else { return false }
|
|
160
|
+
let track = combined.remove(at: fromIdx)
|
|
161
|
+
let clamped = newIndex.clamped(to: 0...combined.count)
|
|
162
|
+
combined.insert(track, at: clamped)
|
|
163
|
+
let pnSize = playNextStack.count
|
|
164
|
+
playNextStack = Array(combined.prefix(pnSize))
|
|
165
|
+
upNextQueue = Array(combined.dropFirst(pnSize))
|
|
166
|
+
if player?.currentItem != nil { rebuildAVQueueFromCurrentPosition() }
|
|
167
|
+
notifyTemporaryQueueChange()
|
|
168
|
+
return true
|
|
149
169
|
}
|
|
150
|
-
|
|
151
170
|
func reorderTemporaryTrack(trackId: String, newIndex: Int) async -> Bool {
|
|
152
|
-
await withPlayerQueueNoThrow {
|
|
153
|
-
var combined = self.playNextStack + self.upNextQueue
|
|
154
|
-
guard let fromIdx = combined.firstIndex(where: { $0.id == trackId }) else { return false }
|
|
155
|
-
let track = combined.remove(at: fromIdx)
|
|
156
|
-
let clamped = newIndex.clamped(to: 0...combined.count)
|
|
157
|
-
combined.insert(track, at: clamped)
|
|
158
|
-
let pnSize = self.playNextStack.count
|
|
159
|
-
self.playNextStack = Array(combined.prefix(pnSize))
|
|
160
|
-
self.upNextQueue = Array(combined.dropFirst(pnSize))
|
|
161
|
-
if self.player?.currentItem != nil { self.rebuildAVQueueFromCurrentPosition() }
|
|
162
|
-
self.notifyTemporaryQueueChange()
|
|
163
|
-
return true
|
|
164
|
-
}
|
|
171
|
+
await withPlayerQueueNoThrow { self.reorderTemporaryTrackOnQueue(trackId: trackId, newIndex: newIndex) }
|
|
165
172
|
}
|
|
166
173
|
|
|
167
174
|
func getPlayNextQueue() async -> [TrackItem] {
|
|
@@ -71,7 +71,7 @@ extension TrackPlayerCore {
|
|
|
71
71
|
for trackId in updatedTrackIds {
|
|
72
72
|
if self.preloadedAssets[trackId] != nil {
|
|
73
73
|
NitroPlayerLogger.log("TrackPlayerCore", "🗑️ Invalidating preloaded asset for track: \(trackId)")
|
|
74
|
-
self.preloadedAssets.removeValue(forKey: trackId)
|
|
74
|
+
self.preloadedAssets.removeValue(forKey: trackId)?.cancelLoading()
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -129,7 +129,7 @@ extension TrackPlayerCore {
|
|
|
129
129
|
// No AVPlayerItem exists yet — lazy-load mode: URLs were empty when the queue first loaded.
|
|
130
130
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
131
131
|
"🔄 No current item — full queue rebuild from currentTrackIndex \(self.currentTrackIndex)")
|
|
132
|
-
|
|
132
|
+
self.removeAllItemsCancellingLoads(player)
|
|
133
133
|
var lastItem: AVPlayerItem? = nil
|
|
134
134
|
for (offset, track) in self.currentTracks[max(0, self.currentTrackIndex)...].enumerated() {
|
|
135
135
|
let isPreload = offset < Constants.gaplessPreloadCount
|
|
@@ -164,17 +164,41 @@ extension TrackPlayerCore {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/// Walks the queue structure directly instead of materializing the whole queue —
|
|
168
|
+
/// this runs on every skip and on the periodic boundary observer, so it must not
|
|
169
|
+
/// be O(playlist) just to read the next few tracks.
|
|
167
170
|
func getNextTracksInternal(count: Int) -> [TrackItem] {
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
guard count > 0 else { return [] }
|
|
172
|
+
var out: [TrackItem] = []
|
|
173
|
+
out.reserveCapacity(count)
|
|
174
|
+
let currentId = activeCurrentTrackId
|
|
175
|
+
|
|
176
|
+
var skippedPlayNext = currentTemporaryType != .playNext
|
|
177
|
+
for track in playNextStack {
|
|
178
|
+
if !skippedPlayNext && track.id == currentId {
|
|
179
|
+
skippedPlayNext = true
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
out.append(track)
|
|
183
|
+
if out.count == count { return out }
|
|
184
|
+
}
|
|
170
185
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
var skippedUpNext = currentTemporaryType != .upNext
|
|
187
|
+
for track in upNextQueue {
|
|
188
|
+
if !skippedUpNext && track.id == currentId {
|
|
189
|
+
skippedUpNext = true
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
out.append(track)
|
|
193
|
+
if out.count == count { return out }
|
|
194
|
+
}
|
|
174
195
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
var index = currentTrackIndex + 1
|
|
197
|
+
while index < currentTracks.count && out.count < count {
|
|
198
|
+
out.append(currentTracks[index])
|
|
199
|
+
index += 1
|
|
200
|
+
}
|
|
201
|
+
return out
|
|
178
202
|
}
|
|
179
203
|
|
|
180
204
|
func checkUpcomingTracksForUrls(lookahead: Int = 5) {
|