react-native-nitro-player 1.5.0 → 1.5.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +71 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +82 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
- package/ios/HybridTrackPlayer.swift +52 -29
- package/ios/core/TrackPlayerCore.swift +9 -1
- package/ios/core/TrackPlayerListener.swift +20 -5
- package/ios/core/TrackPlayerNotify.swift +9 -7
- package/ios/core/TrackPlayerPlayback.swift +75 -39
- package/ios/core/TrackPlayerQueue.swift +28 -4
- package/ios/core/TrackPlayerQueueBuild.swift +100 -40
- package/ios/core/TrackPlayerRecovery.swift +1 -1
- package/ios/core/TrackPlayerTempQueue.swift +129 -122
- package/ios/core/TrackPlayerUrlLoader.swift +34 -10
- package/ios/download/DownloadDatabase.swift +41 -13
- package/ios/media/MediaSessionManager.swift +19 -12
- package/ios/queue/HybridPlayerQueue.swift +10 -5
- package/lib/hooks/callbackManager.d.ts +17 -0
- package/lib/hooks/callbackManager.js +67 -1
- package/lib/hooks/useActualQueue.js +18 -12
- package/lib/hooks/useNowPlaying.js +10 -2
- package/package.json +1 -1
- package/src/components/CastButton.tsx +14 -2
- package/src/hooks/callbackManager.ts +87 -1
- package/src/hooks/useActualQueue.ts +18 -13
- package/src/hooks/useNowPlaying.ts +12 -2
|
@@ -292,7 +292,8 @@ extension TrackPlayerCore {
|
|
|
292
292
|
upNextQueue.removeAll()
|
|
293
293
|
currentTemporaryType = .none
|
|
294
294
|
|
|
295
|
-
let
|
|
295
|
+
let windowed = currentTracks.prefix(1 + Constants.queueWindowSize)
|
|
296
|
+
let allItems = windowed.compactMap { createGaplessPlayerItem(for: $0, isPreload: false) }
|
|
296
297
|
var lastItem: AVPlayerItem? = nil
|
|
297
298
|
for item in allItems {
|
|
298
299
|
player.insert(item, after: lastItem)
|
|
@@ -399,6 +400,15 @@ extension TrackPlayerCore {
|
|
|
399
400
|
setupBoundaryTimeObserver()
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
// Top up the windowed AVQueuePlayer — the item we just consumed freed a slot.
|
|
404
|
+
// Only when it is actually short: rebuildAVQueueFromCurrentPosition can fall into
|
|
405
|
+
// rebuildQueueFromPlaylistIndex (current track no longer in the playlist), which
|
|
406
|
+
// replaces the current item and re-enters this callback. The length check makes any
|
|
407
|
+
// such re-entry a no-op and terminate.
|
|
408
|
+
if player.items().count - 1 < Constants.queueWindowSize {
|
|
409
|
+
rebuildAVQueueFromCurrentPosition()
|
|
410
|
+
}
|
|
411
|
+
|
|
402
412
|
// Preload upcoming tracks for gapless playback
|
|
403
413
|
preloadUpcomingTracks(from: currentTrackIndex + 1)
|
|
404
414
|
cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
|
|
@@ -421,11 +431,12 @@ extension TrackPlayerCore {
|
|
|
421
431
|
self?.player?.automaticallyWaitsToMinimizeStalling = false
|
|
422
432
|
// Update now playing info now that duration is available (capture on playerQueue first)
|
|
423
433
|
let state = self?.getStateInternal()
|
|
424
|
-
let
|
|
434
|
+
let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
|
|
425
435
|
let track = self?.getCurrentTrack()
|
|
426
436
|
DispatchQueue.main.async {
|
|
427
437
|
if let track = track, let state = state {
|
|
428
|
-
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
438
|
+
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
439
|
+
track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
429
440
|
}
|
|
430
441
|
}
|
|
431
442
|
} else if item.status == .failed {
|
|
@@ -438,9 +449,13 @@ extension TrackPlayerCore {
|
|
|
438
449
|
currentItemObservers.append(statusObserver)
|
|
439
450
|
|
|
440
451
|
let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in
|
|
441
|
-
|
|
452
|
+
guard item.isPlaybackBufferEmpty else { return }
|
|
453
|
+
// KVO fires on an arbitrary thread — emitStateChange reads playerQueue-owned
|
|
454
|
+
// state (currentTracks, temp queues, isRecoveringFromStall), so hop first.
|
|
455
|
+
self?.playerQueue.async {
|
|
456
|
+
guard let self, self.player?.currentItem === item else { return }
|
|
442
457
|
NitroPlayerLogger.log("TrackPlayerCore", "⏸️ Buffer empty (buffering)")
|
|
443
|
-
self
|
|
458
|
+
self.emitStateChange()
|
|
444
459
|
}
|
|
445
460
|
}
|
|
446
461
|
currentItemObservers.append(bufferEmptyObserver)
|
|
@@ -13,22 +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
|
-
// Capture state + queue now (on playerQueue), pass pre-computed values to main
|
|
16
|
+
// Capture state + queue metrics now (on playerQueue), pass pre-computed values to main.
|
|
17
|
+
// Metrics, not the queue itself — the media session only needs count and position.
|
|
17
18
|
let state = getStateInternal()
|
|
18
|
-
let
|
|
19
|
+
let metrics = actualQueueMetrics()
|
|
19
20
|
DispatchQueue.main.async { [weak self] in
|
|
20
|
-
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
21
|
+
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
22
|
+
track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
func notifyPlaybackStateChange(_ state: TrackPlayerState, _ reason: Reason?) {
|
|
25
27
|
onPlaybackStateChangeListeners.forEach { $0(state, reason) }
|
|
28
|
+
guard let track = getCurrentTrack() else { return }
|
|
26
29
|
let playerState = getStateInternal()
|
|
27
|
-
let
|
|
28
|
-
let track = getCurrentTrack()
|
|
30
|
+
let metrics = actualQueueMetrics()
|
|
29
31
|
DispatchQueue.main.async { [weak self] in
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
self?.mediaSessionManager?.updateFromPlayerQueue(
|
|
33
|
+
track: track, state: playerState, queueCount: metrics.count, positionInQueue: metrics.position)
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -11,78 +11,98 @@ import MediaPlayer
|
|
|
11
11
|
|
|
12
12
|
extension TrackPlayerCore {
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// MARK: - Public commands
|
|
15
|
+
//
|
|
16
|
+
// Each command has a `…OnQueue` form that MUST run on `playerQueue` and an `async`
|
|
17
|
+
// shim for callers that are not already there (media-session remote commands).
|
|
18
|
+
// HybridTrackPlayer dispatches the `…OnQueue` form directly from the JS thread so
|
|
19
|
+
// the serial queue's FIFO order equals the JS call order — see HybridTrackPlayer.
|
|
20
|
+
//
|
|
21
|
+
// Reading `isCasting` inside the queue block (not before it) keeps every cast-vs-local
|
|
22
|
+
// decision on the thread that owns that flag.
|
|
23
|
+
|
|
24
|
+
func playOnQueue() {
|
|
15
25
|
if isCasting {
|
|
16
26
|
intendedToPlay = true
|
|
17
27
|
castManager?.play()
|
|
18
28
|
return
|
|
19
29
|
}
|
|
20
|
-
|
|
30
|
+
playInternal()
|
|
21
31
|
}
|
|
32
|
+
func play() async { await withPlayerQueueNoThrow { self.playOnQueue() } }
|
|
22
33
|
|
|
23
|
-
func
|
|
34
|
+
func pauseOnQueue() {
|
|
24
35
|
if isCasting {
|
|
25
36
|
intendedToPlay = false
|
|
26
37
|
castManager?.pause()
|
|
27
38
|
return
|
|
28
39
|
}
|
|
29
|
-
|
|
40
|
+
pauseInternal()
|
|
30
41
|
}
|
|
42
|
+
func pause() async { await withPlayerQueueNoThrow { self.pauseOnQueue() } }
|
|
31
43
|
|
|
32
|
-
func
|
|
44
|
+
func seekOnQueue(position: Double) {
|
|
33
45
|
if isCasting {
|
|
34
46
|
castManager?.seek(to: position)
|
|
35
47
|
return
|
|
36
48
|
}
|
|
37
|
-
|
|
49
|
+
seekInternal(position: position)
|
|
50
|
+
}
|
|
51
|
+
func seek(position: Double) async {
|
|
52
|
+
await withPlayerQueueNoThrow { self.seekOnQueue(position: position) }
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
func
|
|
55
|
+
func skipToNextOnQueue() {
|
|
41
56
|
if isCasting {
|
|
42
57
|
castManager?.skipToNext()
|
|
43
58
|
return
|
|
44
59
|
}
|
|
45
|
-
|
|
60
|
+
skipToNextInternal()
|
|
46
61
|
}
|
|
62
|
+
func skipToNext() async { await withPlayerQueueNoThrow { self.skipToNextOnQueue() } }
|
|
47
63
|
|
|
48
|
-
func
|
|
64
|
+
func skipToPreviousOnQueue() {
|
|
49
65
|
if isCasting {
|
|
50
66
|
// The receiver queue starts at the current track, so queuePreviousItem has nothing behind it — use core state instead.
|
|
51
|
-
|
|
67
|
+
skipToPreviousCastInternal()
|
|
52
68
|
return
|
|
53
69
|
}
|
|
54
|
-
|
|
70
|
+
skipToPreviousInternal()
|
|
55
71
|
}
|
|
72
|
+
func skipToPrevious() async { await withPlayerQueueNoThrow { self.skipToPreviousOnQueue() } }
|
|
56
73
|
|
|
74
|
+
func setRepeatModeOnQueue(mode: RepeatMode) {
|
|
75
|
+
currentRepeatMode = mode
|
|
76
|
+
player?.actionAtItemEnd = (mode == .track) ? .none : .advance
|
|
77
|
+
if isCasting { castManager?.setQueueRepeatMode(mode) }
|
|
78
|
+
NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
|
|
79
|
+
}
|
|
57
80
|
func setRepeatMode(mode: RepeatMode) async {
|
|
58
|
-
await withPlayerQueueNoThrow {
|
|
59
|
-
self.currentRepeatMode = mode
|
|
60
|
-
self.player?.actionAtItemEnd = (mode == .track) ? .none : .advance
|
|
61
|
-
if self.isCasting { self.castManager?.setQueueRepeatMode(mode) }
|
|
62
|
-
NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
|
|
63
|
-
}
|
|
81
|
+
await withPlayerQueueNoThrow { self.setRepeatModeOnQueue(mode: mode) }
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
func
|
|
84
|
+
func setVolumeOnQueue(volume: Double) {
|
|
85
|
+
let clamped = max(0.0, min(100.0, volume))
|
|
67
86
|
if isCasting {
|
|
68
|
-
let clamped = max(0.0, min(100.0, volume))
|
|
69
87
|
castManager?.setVolume(Float(clamped / 100.0))
|
|
70
88
|
return
|
|
71
89
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
let normalized = Float(clamped / 100.0)
|
|
91
|
+
player?.volume = normalized
|
|
92
|
+
NitroPlayerLogger.log(
|
|
93
|
+
"TrackPlayerCore", "🔊 Volume set to \(Int(clamped))% (normalized: \(normalized))")
|
|
94
|
+
}
|
|
95
|
+
func setVolume(volume: Double) async {
|
|
96
|
+
await withPlayerQueueNoThrow { self.setVolumeOnQueue(volume: volume) }
|
|
78
97
|
}
|
|
79
98
|
|
|
80
|
-
func
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
func configureOnQueue(
|
|
100
|
+
androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
|
|
101
|
+
showInNotification: Bool?, lookaheadCount: Int?
|
|
102
|
+
) {
|
|
103
|
+
if let la = lookaheadCount {
|
|
104
|
+
self.lookaheadCount = la
|
|
105
|
+
NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(la)")
|
|
86
106
|
}
|
|
87
107
|
DispatchQueue.main.async { [weak self] in
|
|
88
108
|
self?.mediaSessionManager?.configure(
|
|
@@ -92,21 +112,31 @@ extension TrackPlayerCore {
|
|
|
92
112
|
)
|
|
93
113
|
}
|
|
94
114
|
}
|
|
115
|
+
func configure(
|
|
116
|
+
androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
|
|
117
|
+
showInNotification: Bool?, lookaheadCount: Int?
|
|
118
|
+
) async {
|
|
119
|
+
await withPlayerQueueNoThrow {
|
|
120
|
+
self.configureOnQueue(
|
|
121
|
+
androidAutoEnabled: androidAutoEnabled, carPlayEnabled: carPlayEnabled,
|
|
122
|
+
showInNotification: showInNotification, lookaheadCount: lookaheadCount)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
95
125
|
|
|
96
|
-
func
|
|
126
|
+
func setPlaybackSpeedOnQueue(_ speed: Double) {
|
|
127
|
+
currentPlaybackSpeed = speed
|
|
97
128
|
if isCasting {
|
|
98
|
-
currentPlaybackSpeed = speed
|
|
99
129
|
castManager?.setPlaybackRate(Float(speed))
|
|
100
130
|
return
|
|
101
131
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if let player = self.player, player.rate != 0 {
|
|
106
|
-
player.rate = Float(speed)
|
|
107
|
-
}
|
|
132
|
+
// Only update rate if currently playing; pause keeps rate at 0 until play() is called
|
|
133
|
+
if let player = self.player, player.rate != 0 {
|
|
134
|
+
player.rate = Float(speed)
|
|
108
135
|
}
|
|
109
136
|
}
|
|
137
|
+
func setPlaybackSpeed(_ speed: Double) async {
|
|
138
|
+
await withPlayerQueueNoThrow { self.setPlaybackSpeedOnQueue(speed) }
|
|
139
|
+
}
|
|
110
140
|
|
|
111
141
|
func getPlaybackSpeed() async -> Double {
|
|
112
142
|
await withPlayerQueueNoThrow { self.currentPlaybackSpeed }
|
|
@@ -202,6 +232,12 @@ extension TrackPlayerCore {
|
|
|
202
232
|
}
|
|
203
233
|
}
|
|
204
234
|
|
|
235
|
+
// The AVQueuePlayer is windowed, so items().count is not the logical queue length.
|
|
236
|
+
// Top up first so there is always something to advance to while tracks remain.
|
|
237
|
+
if hasUpcomingTrack() {
|
|
238
|
+
if queuePlayer.items().count <= 1 { rebuildAVQueueFromCurrentPosition() }
|
|
239
|
+
}
|
|
240
|
+
|
|
205
241
|
if queuePlayer.items().count > 1 {
|
|
206
242
|
queuePlayer.advanceToNextItem()
|
|
207
243
|
} else {
|
|
@@ -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()
|