react-native-nitro-player 1.7.0-alpha.3 → 1.7.0-alpha.4
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 +9 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +46 -14
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +2 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +36 -12
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +9 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +7 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +8 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +26 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +60 -0
- package/ios/cast/CastQueueDiff.swift +58 -0
- package/ios/cast/CastQueueReconciler.swift +228 -0
- package/ios/core/CastSessionManager.swift +215 -31
- package/ios/core/TrackPlayerCast.swift +96 -65
- package/ios/core/TrackPlayerCore.swift +31 -0
- package/ios/core/TrackPlayerListener.swift +19 -3
- package/ios/core/TrackPlayerPlayback.swift +12 -10
- package/ios/core/TrackPlayerQueue.swift +10 -2
- package/ios/core/TrackPlayerQueueBuild.swift +21 -9
- package/ios/core/TrackPlayerTempQueue.swift +27 -15
- package/ios/core/TrackPlayerUrlLoader.swift +6 -2
- package/ios/playlist/PlaylistManager.swift +60 -1
- package/ios/queue/HybridPlayerQueue.swift +40 -13
- package/lib/hooks/callbackManager.d.ts +2 -0
- package/lib/hooks/callbackManager.js +23 -8
- package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
- package/lib/types/PlayerQueue.d.ts +14 -0
- package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
- package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
- package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
- package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
- package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
- package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +25 -10
- package/src/specs/TrackPlayer.nitro.ts +11 -0
- package/src/types/PlayerQueue.ts +17 -0
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
import AVFoundation
|
|
17
17
|
import Foundation
|
|
18
18
|
|
|
19
|
+
/// Receiver state captured before the Cast session tears its caches down.
|
|
20
|
+
struct CastTransferSnapshot {
|
|
21
|
+
let trackId: String?
|
|
22
|
+
let position: Double
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
extension TrackPlayerCore {
|
|
20
26
|
|
|
21
27
|
// MARK: - Cast-safety helpers
|
|
@@ -62,44 +68,67 @@ extension TrackPlayerCore {
|
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/// The Cast session ended — rebuild local playback at the last remote position.
|
|
65
|
-
func handleCastDisconnected() {
|
|
71
|
+
func handleCastDisconnected(_ snapshot: CastTransferSnapshot) {
|
|
66
72
|
playerQueue.async { [weak self] in
|
|
67
73
|
guard let self else { return }
|
|
68
74
|
guard self.isCasting else { return }
|
|
69
|
-
let resumePosition =
|
|
70
|
-
let remoteTrackId =
|
|
75
|
+
let resumePosition = snapshot.position
|
|
76
|
+
let remoteTrackId = snapshot.trackId
|
|
71
77
|
self.isCasting = false
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// A backend transfer is not a track change.
|
|
80
|
+
self.backendTransferTargetId = remoteTrackId
|
|
81
|
+
let hadAnchor = self.currentTrackIndex >= 0
|
|
82
|
+
let index = max(0, self.currentTrackIndex)
|
|
75
83
|
|
|
76
84
|
// rebuildQueueFromPlaylistIndex wipes the temp lists — preserve them
|
|
77
85
|
let savedPlayNext = self.playNextStack
|
|
78
86
|
let savedUpNext = self.upNextQueue
|
|
87
|
+
let remoteTemp = remoteTrackId.flatMap { id in
|
|
88
|
+
savedPlayNext.first { $0.id == id } ?? savedUpNext.first { $0.id == id }
|
|
89
|
+
}
|
|
79
90
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
if self.currentTracks.isEmpty {
|
|
92
|
+
// Playlist emptied while casting: bootstrap the local player from the remote temp.
|
|
93
|
+
guard let player = self.player, let temp = remoteTemp,
|
|
94
|
+
let item = self.createGaplessPlayerItem(for: temp, isPreload: false)
|
|
95
|
+
else {
|
|
96
|
+
self.backendTransferTargetId = nil
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
self.removeAllItemsCancellingLoads(player)
|
|
100
|
+
player.insert(item, after: nil)
|
|
101
|
+
self.currentTemporaryType = savedPlayNext.contains { $0.id == temp.id } ? .playNext : .upNext
|
|
102
|
+
self.currentTrackIndex = -1
|
|
85
103
|
self.rebuildAVQueueFromCurrentPosition()
|
|
86
104
|
self.notifyTemporaryQueueChange()
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if
|
|
93
|
-
self.playNextStack
|
|
94
|
-
self.
|
|
95
|
-
self.
|
|
96
|
-
self.notifyTemporaryQueueChange()
|
|
97
|
-
} else if self.playNextStack.isEmpty, self.upNextQueue.first?.id == target {
|
|
98
|
-
self.upNextQueue.removeFirst()
|
|
99
|
-
self.player?.advanceToNextItem()
|
|
100
|
-
self.currentTemporaryType = .upNext
|
|
105
|
+
} else {
|
|
106
|
+
guard self.rebuildQueueFromPlaylistIndex(index: index, emitChange: false) else {
|
|
107
|
+
self.backendTransferTargetId = nil
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if !savedPlayNext.isEmpty || !savedUpNext.isEmpty {
|
|
111
|
+
self.playNextStack = savedPlayNext
|
|
112
|
+
self.upNextQueue = savedUpNext
|
|
113
|
+
self.rebuildAVQueueFromCurrentPosition()
|
|
101
114
|
self.notifyTemporaryQueueChange()
|
|
102
115
|
}
|
|
116
|
+
|
|
117
|
+
// The temp stays in its list; the builder skips the current id and transitions drop it.
|
|
118
|
+
if let target = remoteTrackId, target != self.player?.currentItem?.trackId {
|
|
119
|
+
if self.playNextStack.first?.id == target {
|
|
120
|
+
self.player?.advanceToNextItem()
|
|
121
|
+
self.currentTemporaryType = .playNext
|
|
122
|
+
} else if self.playNextStack.isEmpty, self.upNextQueue.first?.id == target {
|
|
123
|
+
self.player?.advanceToNextItem()
|
|
124
|
+
self.currentTemporaryType = .upNext
|
|
125
|
+
}
|
|
126
|
+
// The advance consumed track 0; re-queue it after the temps.
|
|
127
|
+
if !hadAnchor, self.currentTemporaryType != .none {
|
|
128
|
+
self.currentTrackIndex = -1
|
|
129
|
+
self.rebuildAVQueueFromCurrentPosition()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
103
132
|
}
|
|
104
133
|
|
|
105
134
|
// Only seek when the local current track is the one that was playing remotely
|
|
@@ -111,6 +140,7 @@ extension TrackPlayerCore {
|
|
|
111
140
|
if self.intendedToPlay {
|
|
112
141
|
self.player?.rate = Float(self.currentPlaybackSpeed)
|
|
113
142
|
}
|
|
143
|
+
if self.player?.currentItem?.trackId == remoteTrackId { self.backendTransferTargetId = nil }
|
|
114
144
|
}
|
|
115
145
|
}
|
|
116
146
|
|
|
@@ -127,14 +157,36 @@ extension TrackPlayerCore {
|
|
|
127
157
|
loadCastQueue(fromActualIndex: startIndex, autoplay: autoplay, position: position)
|
|
128
158
|
}
|
|
129
159
|
|
|
130
|
-
///
|
|
160
|
+
/// The whole castable queue, so a shuffle can reorder items before the current one. Nil when the current is lazy.
|
|
161
|
+
func castableQueue(_ actual: [TrackItem], currentIdx: Int) -> (tracks: [TrackItem], startIndex: Int)? {
|
|
162
|
+
guard currentIdx >= 0, currentIdx < actual.count else { return nil }
|
|
163
|
+
let after = castableUpcoming(Array(actual[currentIdx...]))
|
|
164
|
+
guard !after.isEmpty else { return nil }
|
|
165
|
+
let before = actual[..<currentIdx].filter { isTrackCastable($0) }
|
|
166
|
+
return (before + after, before.count)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// A removed track finishing is no longer in the logical queue, so the receiver's own current anchors it.
|
|
170
|
+
func castDesiredQueue() -> (tracks: [TrackItem], currentId: String)? {
|
|
171
|
+
guard let currentId = activeCurrentTrackId else { return nil }
|
|
172
|
+
let actual = getActualQueueInternal()
|
|
173
|
+
if let idx = actual.firstIndex(where: { $0.id == currentId }) {
|
|
174
|
+
guard let built = castableQueue(actual, currentIdx: idx) else { return nil }
|
|
175
|
+
return (built.tracks, currentId)
|
|
176
|
+
}
|
|
177
|
+
guard let remoteCurrent = castManager?.currentRemoteTrackItem else { return nil }
|
|
178
|
+
castManager?.setQueueRepeatMode(.off)
|
|
179
|
+
let rest = currentTrackIndex + 1 < currentTracks.count
|
|
180
|
+
? Array(currentTracks[(currentTrackIndex + 1)...]) : []
|
|
181
|
+
return ([remoteCurrent] + castableUpcoming(playNextStack + upNextQueue + rest), remoteCurrent.id)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// Atomically (re)load the receiver queue positioned at `index` in the actual queue (lazy targets stop the receiver and wait for updateTracks).
|
|
131
185
|
func loadCastQueue(fromActualIndex index: Int, autoplay: Bool, position: Double) {
|
|
132
186
|
let queue = getActualQueueInternal()
|
|
133
187
|
guard index >= 0, index < queue.count else { return }
|
|
134
|
-
let slice = Array(queue[index...])
|
|
135
|
-
let playable = castableUpcoming(slice)
|
|
136
188
|
|
|
137
|
-
guard
|
|
189
|
+
guard let built = castableQueue(queue, currentIdx: index) else {
|
|
138
190
|
// Lazy target — silence the receiver and wait for updateTracks to reload.
|
|
139
191
|
castManager?.stop()
|
|
140
192
|
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
@@ -142,19 +194,21 @@ extension TrackPlayerCore {
|
|
|
142
194
|
}
|
|
143
195
|
|
|
144
196
|
// Resume mid-track only when the receiver starts on the requested track.
|
|
145
|
-
let
|
|
197
|
+
let startTrack = built.tracks[built.startIndex]
|
|
198
|
+
let effectivePosition = startTrack.id == queue[index].id ? position : 0
|
|
146
199
|
|
|
147
|
-
castManager?.setExpectedCurrentTrack(
|
|
200
|
+
castManager?.setExpectedCurrentTrack(startTrack.id)
|
|
148
201
|
castManager?.loadQueue(
|
|
149
|
-
tracks:
|
|
202
|
+
tracks: built.tracks,
|
|
203
|
+
startIndex: built.startIndex,
|
|
150
204
|
position: effectivePosition,
|
|
151
205
|
autoplay: autoplay,
|
|
152
|
-
repeatMode: currentRepeatMode
|
|
206
|
+
repeatMode: isTransientPlayOut() ? .off : currentRepeatMode
|
|
153
207
|
)
|
|
154
208
|
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
155
209
|
}
|
|
156
210
|
|
|
157
|
-
///
|
|
211
|
+
/// Current-preserving operations; the reconciler reloads only when receiver state is untrusted.
|
|
158
212
|
func syncCastQueueAfterCurrent() {
|
|
159
213
|
guard let cast = castManager else { return }
|
|
160
214
|
guard cast.hasLoadedMedia else {
|
|
@@ -162,38 +216,8 @@ extension TrackPlayerCore {
|
|
|
162
216
|
loadCastQueue(autoplay: intendedToPlay, position: 0)
|
|
163
217
|
return
|
|
164
218
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
guard let current = getCurrentTrack(),
|
|
168
|
-
let currentIdx = queue.firstIndex(where: { $0.id == current.id })
|
|
169
|
-
else { return }
|
|
170
|
-
|
|
171
|
-
let desired = castableUpcoming(Array(queue[(currentIdx + 1)...]))
|
|
172
|
-
let desiredIds = desired.map { $0.id }
|
|
173
|
-
|
|
174
|
-
let loaded = cast.loadedTrackIds
|
|
175
|
-
let existingUpcoming: [String]
|
|
176
|
-
if let ei = loaded.firstIndex(of: current.id) {
|
|
177
|
-
existingUpcoming = Array(loaded[(ei + 1)...])
|
|
178
|
-
} else {
|
|
179
|
-
existingUpcoming = []
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if desiredIds == existingUpcoming { return } // already in sync
|
|
183
|
-
|
|
184
|
-
if desiredIds.count > existingUpcoming.count,
|
|
185
|
-
Array(desiredIds.prefix(existingUpcoming.count)) == existingUpcoming {
|
|
186
|
-
// Strict tail extension — append without touching current playback.
|
|
187
|
-
let toAppend = Array(desired.suffix(desiredIds.count - existingUpcoming.count))
|
|
188
|
-
castManager?.appendToQueue(tracks: toAppend)
|
|
189
|
-
} else {
|
|
190
|
-
// Order changed / items removed — atomic reload preserving position.
|
|
191
|
-
loadCastQueue(
|
|
192
|
-
fromActualIndex: currentIdx,
|
|
193
|
-
autoplay: intendedToPlay,
|
|
194
|
-
position: cast.lastKnownRemotePosition
|
|
195
|
-
)
|
|
196
|
-
}
|
|
219
|
+
guard let desired = castDesiredQueue() else { return }
|
|
220
|
+
cast.reconcileQueue(desired: desired.tracks, currentTrackId: desired.currentId)
|
|
197
221
|
}
|
|
198
222
|
|
|
199
223
|
// MARK: - Cast-mode navigation (mirrors the local skip logic, minus AVQueuePlayer)
|
|
@@ -273,6 +297,11 @@ extension TrackPlayerCore {
|
|
|
273
297
|
}
|
|
274
298
|
|
|
275
299
|
if currentTemporaryType != .none {
|
|
300
|
+
// No playlist track to return to: restart the temp without touching its list.
|
|
301
|
+
if currentTracks.isEmpty {
|
|
302
|
+
castManager?.seek(to: 0)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
276
305
|
// Leaving a temp track removes it, then return to the current original.
|
|
277
306
|
if let currentId = activeCurrentTrackId {
|
|
278
307
|
if currentTemporaryType == .playNext,
|
|
@@ -284,6 +313,8 @@ extension TrackPlayerCore {
|
|
|
284
313
|
}
|
|
285
314
|
}
|
|
286
315
|
currentTemporaryType = .none
|
|
316
|
+
// Cursor may be -1 (anchor removed): return to the resume slot.
|
|
317
|
+
currentTrackIndex = max(0, currentTrackIndex)
|
|
287
318
|
notifyTemporaryQueueChange()
|
|
288
319
|
} else if currentTrackIndex > 0 {
|
|
289
320
|
currentTrackIndex -= 1
|
|
@@ -52,6 +52,8 @@ class TrackPlayerCore: NSObject {
|
|
|
52
52
|
// whose generation is stale was superseded by a later request and is dropped,
|
|
53
53
|
// so a burst of playlist mutations collapses into a single rebuild.
|
|
54
54
|
internal var playlistUpdateGeneration: UInt64 = 0
|
|
55
|
+
// Set by handleCastDisconnected: suppresses onChangeTrack until the local player reaches this id.
|
|
56
|
+
internal var backendTransferTargetId: String?
|
|
55
57
|
internal var isManuallySeeked = false
|
|
56
58
|
internal var currentRepeatMode: RepeatMode = .off
|
|
57
59
|
internal var currentPlaybackSpeed: Double = 1.0
|
|
@@ -266,6 +268,7 @@ class TrackPlayerCore: NSObject {
|
|
|
266
268
|
}
|
|
267
269
|
self.currentItemObservers.removeAll()
|
|
268
270
|
self.detachMetadataOutput()
|
|
271
|
+
self.backendTransferTargetId = nil
|
|
269
272
|
if let p = self.player {
|
|
270
273
|
p.removeObserver(self, forKeyPath: "status")
|
|
271
274
|
p.removeObserver(self, forKeyPath: "rate")
|
|
@@ -288,6 +291,34 @@ class TrackPlayerCore: NSObject {
|
|
|
288
291
|
}
|
|
289
292
|
}
|
|
290
293
|
|
|
294
|
+
enum AnchorOutcome { case none, kept, removed }
|
|
295
|
+
|
|
296
|
+
extension TrackPlayerCore {
|
|
297
|
+
/// Re-resolves the anchor by id, or moves the cursor to the resume slot when it was removed.
|
|
298
|
+
@discardableResult
|
|
299
|
+
func assignCurrentTracks(_ next: [TrackItem]) -> AnchorOutcome {
|
|
300
|
+
let old = currentTracks
|
|
301
|
+
let anchor = old[safe: currentTrackIndex]
|
|
302
|
+
currentTracks = next
|
|
303
|
+
guard let anchor else { return .none }
|
|
304
|
+
if let newIndex = next.firstIndex(where: { $0.id == anchor.id }) {
|
|
305
|
+
currentTrackIndex = newIndex
|
|
306
|
+
return .kept
|
|
307
|
+
}
|
|
308
|
+
let alive = Set(next.map(\.id))
|
|
309
|
+
let followerId = old.dropFirst(currentTrackIndex + 1).first { alive.contains($0.id) }?.id
|
|
310
|
+
let resume = followerId.flatMap { id in next.firstIndex { $0.id == id } } ?? next.count
|
|
311
|
+
currentTrackIndex = resume - 1
|
|
312
|
+
return .removed
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/// True while the playing item was removed from the playlist and is finishing.
|
|
316
|
+
func isTransientPlayOut() -> Bool {
|
|
317
|
+
guard currentTemporaryType == .none, let id = activeCurrentTrackId else { return false }
|
|
318
|
+
return !currentTracks.contains { $0.id == id }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
291
322
|
// Safe array subscript
|
|
292
323
|
extension Array {
|
|
293
324
|
subscript(safe index: Int) -> Element? {
|
|
@@ -172,8 +172,12 @@ extension TrackPlayerCore {
|
|
|
172
172
|
// Items without our trackId belong to another AVPlayer in the host app
|
|
173
173
|
guard finishedItem.trackId != nil else { return }
|
|
174
174
|
|
|
175
|
+
// A track removed from the playlist is finishing: it must play once, never loop.
|
|
176
|
+
let finishedWasRemoved =
|
|
177
|
+
finishedItem.trackId.map { id in !currentTracks.contains { $0.id == id } } ?? false
|
|
178
|
+
|
|
175
179
|
// 1. TRACK repeat — handle FIRST, before any temp-track removal
|
|
176
|
-
if currentRepeatMode == .track {
|
|
180
|
+
if currentRepeatMode == .track && !finishedWasRemoved {
|
|
177
181
|
if player?.currentItem == nil || player?.currentItem === finishedItem {
|
|
178
182
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — no queued copy, seeking to zero")
|
|
179
183
|
player?.seek(to: .zero)
|
|
@@ -317,6 +321,12 @@ extension TrackPlayerCore {
|
|
|
317
321
|
notifyTrackChange(firstTrack, .repeat)
|
|
318
322
|
}
|
|
319
323
|
notifyTemporaryQueueChange()
|
|
324
|
+
} else if let player = self.player, player.items().isEmpty, !hasUpcomingTrack() {
|
|
325
|
+
// AVQueuePlayer leaves `rate` at 1 with no item left, so state would keep reading as
|
|
326
|
+
// playing after the last track ends. Stop for real and tell JS.
|
|
327
|
+
player.rate = 0
|
|
328
|
+
intendedToPlay = false
|
|
329
|
+
emitStateChange(reason: .end)
|
|
320
330
|
}
|
|
321
331
|
return
|
|
322
332
|
}
|
|
@@ -362,6 +372,12 @@ extension TrackPlayerCore {
|
|
|
362
372
|
// Setup KVO observers for current item
|
|
363
373
|
setupCurrentItemObservers(item: currentItem)
|
|
364
374
|
|
|
375
|
+
// A Cast disconnect rebuilds around the receiver's track; none of those transitions is a change.
|
|
376
|
+
let isBackendTransfer = backendTransferTargetId != nil
|
|
377
|
+
if let target = backendTransferTargetId, currentItem.trackId == target {
|
|
378
|
+
backendTransferTargetId = nil
|
|
379
|
+
}
|
|
380
|
+
|
|
365
381
|
// Update track index and determine temporary type
|
|
366
382
|
if let trackId = currentItem.trackId {
|
|
367
383
|
NitroPlayerLogger.log("TrackPlayerCore", "🔍 Looking up trackId '\(trackId)' in currentTracks...")
|
|
@@ -375,7 +391,7 @@ extension TrackPlayerCore {
|
|
|
375
391
|
if currentTemporaryType == .playNext { tempTrack = playNextStack.first(where: { $0.id == trackId }) }
|
|
376
392
|
else if currentTemporaryType == .upNext { tempTrack = upNextQueue.first(where: { $0.id == trackId }) }
|
|
377
393
|
if let track = tempTrack {
|
|
378
|
-
if isRecoveryReplace || isRepeatLoop {
|
|
394
|
+
if isRecoveryReplace || isRepeatLoop || isBackendTransfer {
|
|
379
395
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
380
396
|
" ♻️ Same-track transition for '\(track.title)' — suppressing onChangeTrack")
|
|
381
397
|
} else {
|
|
@@ -393,7 +409,7 @@ extension TrackPlayerCore {
|
|
|
393
409
|
|
|
394
410
|
if let track = currentTracks[safe: index] {
|
|
395
411
|
NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Track: \(track.title) - \(track.artist)")
|
|
396
|
-
if oldIndex != index {
|
|
412
|
+
if oldIndex != index && !isBackendTransfer {
|
|
397
413
|
NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack (index changed from \(oldIndex) to \(index))")
|
|
398
414
|
let wrappedToStart =
|
|
399
415
|
currentRepeatMode == .playlist && index == 0 && oldIndex == currentTracks.count - 1
|
|
@@ -75,7 +75,8 @@ extension TrackPlayerCore {
|
|
|
75
75
|
let previousMode = currentRepeatMode
|
|
76
76
|
currentRepeatMode = mode
|
|
77
77
|
player?.actionAtItemEnd = .advance
|
|
78
|
-
|
|
78
|
+
// A transient play-out (removed item finishing) must not loop, whatever the user picks.
|
|
79
|
+
if isCasting { castManager?.setQueueRepeatMode(isTransientPlayOut() ? .off : mode) }
|
|
79
80
|
if !isCasting, previousMode != mode, player?.currentItem != nil {
|
|
80
81
|
rebuildAVQueueFromCurrentPosition()
|
|
81
82
|
}
|
|
@@ -258,13 +259,7 @@ extension TrackPlayerCore {
|
|
|
258
259
|
return
|
|
259
260
|
}
|
|
260
261
|
|
|
261
|
-
|
|
262
|
-
let items = queuePlayer.items()
|
|
263
|
-
if items.count > 1, let copyId = items[1].trackId, copyId == items[0].trackId {
|
|
264
|
-
queuePlayer.remove(items[1])
|
|
265
|
-
}
|
|
266
|
-
rebuildAVQueueFromCurrentPosition(skipRepeatCopy: true)
|
|
267
|
-
}
|
|
262
|
+
dropRepeatCopyBeforeManualSkip(queuePlayer)
|
|
268
263
|
|
|
269
264
|
// Remove current temp track from its list before advancing
|
|
270
265
|
if let trackId = queuePlayer.currentItem?.trackId {
|
|
@@ -308,6 +303,12 @@ extension TrackPlayerCore {
|
|
|
308
303
|
// If more than threshold seconds in, restart current track
|
|
309
304
|
queuePlayer.seek(to: .zero)
|
|
310
305
|
} else if self.currentTemporaryType != .none {
|
|
306
|
+
// No playlist track to return to (all removed): restart the temp without touching its list.
|
|
307
|
+
if currentTracks.isEmpty {
|
|
308
|
+
queuePlayer.seek(to: .zero)
|
|
309
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
311
312
|
// Playing temporary track — remove from its list, then go back to original track
|
|
312
313
|
if let trackId = queuePlayer.currentItem?.trackId {
|
|
313
314
|
if currentTemporaryType == .playNext, let idx = playNextStack.firstIndex(where: { $0.id == trackId }) {
|
|
@@ -318,8 +319,8 @@ extension TrackPlayerCore {
|
|
|
318
319
|
notifyTemporaryQueueChange()
|
|
319
320
|
}
|
|
320
321
|
}
|
|
321
|
-
// Go back to current original track position (skip back from temp)
|
|
322
|
-
_ = rebuildQueueFromPlaylistIndex(index: self.currentTrackIndex)
|
|
322
|
+
// Go back to current original track position (skip back from temp); cursor may be -1 (anchor removed) → resume slot 0
|
|
323
|
+
_ = rebuildQueueFromPlaylistIndex(index: max(0, self.currentTrackIndex))
|
|
323
324
|
} else if self.currentTrackIndex > 0 {
|
|
324
325
|
// Go to previous track in original playlist
|
|
325
326
|
_ = rebuildQueueFromPlaylistIndex(index: self.currentTrackIndex - 1)
|
|
@@ -398,6 +399,7 @@ extension TrackPlayerCore {
|
|
|
398
399
|
return
|
|
399
400
|
}
|
|
400
401
|
|
|
402
|
+
playlistManager.setCurrentPlaylistId(playlistId)
|
|
401
403
|
if self.currentPlaylistId != playlistId {
|
|
402
404
|
NitroPlayerLogger.log("TrackPlayerCore", "🔄 Loading new playlist: \(playlistId)")
|
|
403
405
|
if let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) {
|
|
@@ -218,7 +218,11 @@ extension TrackPlayerCore {
|
|
|
218
218
|
// (rebuildAVQueueFromCurrentPosition will skip it by ID)
|
|
219
219
|
playNextStack.removeSubrange(0..<targetIdx)
|
|
220
220
|
}
|
|
221
|
-
|
|
221
|
+
if let player = player, currentRepeatMode == .track {
|
|
222
|
+
dropRepeatCopyBeforeManualSkip(player)
|
|
223
|
+
} else {
|
|
224
|
+
rebuildAVQueueFromCurrentPosition()
|
|
225
|
+
}
|
|
222
226
|
player?.advanceToNextItem()
|
|
223
227
|
return true
|
|
224
228
|
}
|
|
@@ -231,7 +235,11 @@ extension TrackPlayerCore {
|
|
|
231
235
|
if let targetIdx = upNextQueue.firstIndex(where: { $0.id == targetTrack.id }), targetIdx > 0 {
|
|
232
236
|
upNextQueue.removeSubrange(0..<targetIdx)
|
|
233
237
|
}
|
|
234
|
-
|
|
238
|
+
if let player = player, currentRepeatMode == .track {
|
|
239
|
+
dropRepeatCopyBeforeManualSkip(player)
|
|
240
|
+
} else {
|
|
241
|
+
rebuildAVQueueFromCurrentPosition()
|
|
242
|
+
}
|
|
235
243
|
player?.advanceToNextItem()
|
|
236
244
|
return true
|
|
237
245
|
}
|
|
@@ -39,6 +39,17 @@ extension TrackPlayerCore {
|
|
|
39
39
|
player.removeAllItems()
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/// Under repeat `track` the queue holds a copy of the current item; a manual skip must
|
|
43
|
+
/// step past it to the real next track, not replay the same one.
|
|
44
|
+
func dropRepeatCopyBeforeManualSkip(_ player: AVQueuePlayer) {
|
|
45
|
+
guard currentRepeatMode == .track else { return }
|
|
46
|
+
let items = player.items()
|
|
47
|
+
if items.count > 1, let copyId = items[1].trackId, copyId == items[0].trackId {
|
|
48
|
+
player.remove(items[1])
|
|
49
|
+
}
|
|
50
|
+
rebuildAVQueueFromCurrentPosition(skipRepeatCopy: true)
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
func updatePlayerQueue(tracks: [TrackItem]) {
|
|
43
54
|
// While casting, mirror the new queue to the Cast device instead of building
|
|
44
55
|
// the local AVQueuePlayer (which would start local audio).
|
|
@@ -71,6 +82,7 @@ extension TrackPlayerCore {
|
|
|
71
82
|
// Store tracks for index tracking
|
|
72
83
|
currentTracks = tracks
|
|
73
84
|
currentTrackIndex = 0
|
|
85
|
+
backendTransferTargetId = nil
|
|
74
86
|
NitroPlayerLogger.log("TrackPlayerCore", "🔢 Reset currentTrackIndex to 0 (will be updated by KVO observer)")
|
|
75
87
|
|
|
76
88
|
// Remove old boundary observer if exists
|
|
@@ -167,7 +179,9 @@ extension TrackPlayerCore {
|
|
|
167
179
|
/// Clears temporary tracks, rebuilds AVQueuePlayer from `index` in the original playlist,
|
|
168
180
|
/// and resumes playback only if the player was already playing (preserves paused state).
|
|
169
181
|
@discardableResult
|
|
170
|
-
func rebuildQueueFromPlaylistIndex(index: Int) -> Bool {
|
|
182
|
+
func rebuildQueueFromPlaylistIndex(index: Int, emitChange: Bool = true) -> Bool {
|
|
183
|
+
// A real jump supersedes any in-progress backend transfer.
|
|
184
|
+
if emitChange { backendTransferTargetId = nil }
|
|
171
185
|
guard index >= 0 && index < self.currentTracks.count else {
|
|
172
186
|
NitroPlayerLogger.log("TrackPlayerCore", "❌ rebuildQueueFromPlaylistIndex - invalid index \(index), currentTracks.count = \(self.currentTracks.count)")
|
|
173
187
|
return false
|
|
@@ -216,7 +230,7 @@ extension TrackPlayerCore {
|
|
|
216
230
|
}
|
|
217
231
|
if let p = player { removeAllItemsCancellingLoads(p) }
|
|
218
232
|
self.currentTracks = fullPlaylist
|
|
219
|
-
if let track = self.currentTracks[safe: index] {
|
|
233
|
+
if emitChange, let track = self.currentTracks[safe: index] {
|
|
220
234
|
notifyTrackChange(track, .skip)
|
|
221
235
|
}
|
|
222
236
|
return true
|
|
@@ -256,7 +270,7 @@ extension TrackPlayerCore {
|
|
|
256
270
|
self.currentTracks = fullPlaylist
|
|
257
271
|
|
|
258
272
|
NitroPlayerLogger.log("TrackPlayerCore", " ✅ Gapless queue recreated. Now at index: \(self.currentTrackIndex)")
|
|
259
|
-
if let track = self.getCurrentTrack() {
|
|
273
|
+
if emitChange, let track = self.getCurrentTrack() {
|
|
260
274
|
NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Playing: \(track.title)")
|
|
261
275
|
notifyTrackChange(track, .skip)
|
|
262
276
|
}
|
|
@@ -296,14 +310,12 @@ extension TrackPlayerCore {
|
|
|
296
310
|
// delegate to rebuildQueueFromPlaylistIndex so the player immediately
|
|
297
311
|
// starts what is now at currentTrackIndex in the updated list.
|
|
298
312
|
if !currentTracks.contains(where: { $0.id == playingTrackId }) &&
|
|
299
|
-
currentTemporaryType == .none {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if targetIndex >= 0 {
|
|
303
|
-
_ = rebuildQueueFromPlaylistIndex(index: targetIndex)
|
|
304
|
-
}
|
|
313
|
+
currentTemporaryType == .none && !currentTracks.isEmpty {
|
|
314
|
+
// currentTrackIndex + 1 is the resume slot set by assignCurrentTracks.
|
|
315
|
+
_ = rebuildQueueFromPlaylistIndex(index: min(currentTrackIndex + 1, currentTracks.count - 1))
|
|
305
316
|
return
|
|
306
317
|
}
|
|
318
|
+
// Removed item still playing: fall through so the diff keeps only the temps.
|
|
307
319
|
|
|
308
320
|
// Sync currentTrackIndex to the track's actual position after a playlist mutation
|
|
309
321
|
// (e.g. reorder). Without this, the remaining-tracks slice uses the stale index,
|
|
@@ -74,25 +74,37 @@ extension TrackPlayerCore {
|
|
|
74
74
|
|
|
75
75
|
// Run at the tail of the current burst; stale generations drop out.
|
|
76
76
|
self.playerQueue.async { [weak self] in
|
|
77
|
-
guard let self, generation == self.playlistUpdateGeneration
|
|
78
|
-
|
|
79
|
-
let playlist = self.playlistManager.getPlaylist(playlistId: playlistId) else { return }
|
|
80
|
-
|
|
81
|
-
// If nothing is playing yet, do a full load
|
|
82
|
-
guard self.player?.currentItem != nil else {
|
|
83
|
-
self.updatePlayerQueue(tracks: playlist.tracks)
|
|
84
|
-
self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Update tracks list without interrupting playback
|
|
89
|
-
self.currentTracks = playlist.tracks
|
|
90
|
-
self.rebuildAVQueueFromCurrentPosition()
|
|
91
|
-
self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
|
|
77
|
+
guard let self, generation == self.playlistUpdateGeneration else { return }
|
|
78
|
+
self.syncCurrentPlaylistOnQueue(playlistId: playlistId)
|
|
92
79
|
}
|
|
93
80
|
}
|
|
94
81
|
}
|
|
95
82
|
|
|
83
|
+
/// Applies the stored playlist to the live queue without re-preparing the playing item.
|
|
84
|
+
func syncCurrentPlaylistOnQueue(playlistId: String) {
|
|
85
|
+
playlistUpdateGeneration &+= 1
|
|
86
|
+
guard currentPlaylistId == playlistId,
|
|
87
|
+
let playlist = playlistManager.getPlaylist(playlistId: playlistId) else { return }
|
|
88
|
+
|
|
89
|
+
// If nothing is playing yet, do a full load
|
|
90
|
+
guard isCasting || player?.currentItem != nil else {
|
|
91
|
+
updatePlayerQueue(tracks: playlist.tracks)
|
|
92
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let outcome = assignCurrentTracks(playlist.tracks)
|
|
97
|
+
if isCasting, outcome == .removed, currentTemporaryType == .none, !currentTracks.isEmpty {
|
|
98
|
+
// The receiver's current item was removed: jump to the resume slot.
|
|
99
|
+
_ = rebuildQueueFromPlaylistIndex(index: min(currentTrackIndex + 1, currentTracks.count - 1))
|
|
100
|
+
} else {
|
|
101
|
+
// No preload here: the rebuild queues the new items and currentItemDidChange preloads on the
|
|
102
|
+
// next transition. Kicking off asset loads now competes with the item that is still playing.
|
|
103
|
+
rebuildAVQueueFromCurrentPosition()
|
|
104
|
+
}
|
|
105
|
+
checkUpcomingTracksForUrls(lookahead: lookaheadCount)
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
func playNextOnQueue(trackId: String) throws {
|
|
97
109
|
guard let track = findTrackById(trackId) else {
|
|
98
110
|
throw NSError(domain: "NitroPlayer", code: 404, userInfo: [NSLocalizedDescriptionKey: "Track \(trackId) not found"])
|
|
@@ -49,9 +49,12 @@ extension TrackPlayerCore {
|
|
|
49
49
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
50
50
|
"🔄 Updating current track (\(currentItemFailed ? "failed item" : "no URL")): \(track.id)")
|
|
51
51
|
return !track.url.isEmpty
|
|
52
|
+
case track.id == currentTrackId && track.url == currentTrack?.url && !track.url.isEmpty:
|
|
53
|
+
// Same URL is a metadata-only edit (a title or artwork refresh); the item is untouched.
|
|
54
|
+
return true
|
|
52
55
|
case track.id == currentTrackId:
|
|
53
56
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
54
|
-
"⚠️ Skipping
|
|
57
|
+
"⚠️ Skipping URL change for currently playing track: \(track.id) (preserves gapless)")
|
|
55
58
|
return false
|
|
56
59
|
case track.url.isEmpty:
|
|
57
60
|
NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Skipping track with empty URL: \(track.id)")
|
|
@@ -98,7 +101,7 @@ extension TrackPlayerCore {
|
|
|
98
101
|
|
|
99
102
|
// Sync currentTracks from the freshly-updated PlaylistManager
|
|
100
103
|
if let updatedPlaylist = self.playlistManager.getPlaylist(playlistId: currentId) {
|
|
101
|
-
self.
|
|
104
|
+
self.assignCurrentTracks(updatedPlaylist.tracks)
|
|
102
105
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
103
106
|
"📥 Synced currentTracks from PlaylistManager (\(self.currentTracks.count) tracks)")
|
|
104
107
|
}
|
|
@@ -159,6 +162,7 @@ extension TrackPlayerCore {
|
|
|
159
162
|
|
|
160
163
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
161
164
|
"✅ Track updates complete - \(affectedPlaylists.count) playlists affected")
|
|
165
|
+
refreshMediaSession()
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
func getTracksNeedingUrlsInternal() -> [TrackItem] {
|