react-native-nitro-player 1.7.0-alpha.3 → 1.7.0-alpha.5

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.
Files changed (98) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/AndroidManifest.xml +8 -0
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +51 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +2 -2
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +17 -2
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/NitroPlayerLogger.kt +2 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +41 -31
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +1 -1
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +92 -15
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +12 -17
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +39 -13
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +25 -4
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerRecovery.kt +97 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +0 -5
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +22 -5
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +3 -0
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +6 -0
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +2 -0
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
  25. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaBrowserService.kt +96 -45
  26. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibrary.kt +2 -0
  27. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryManager.kt +147 -0
  28. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryParser.kt +3 -0
  29. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +102 -24
  30. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +51 -3
  31. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +82 -1
  32. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +75 -0
  33. package/ios/cast/CastQueueDiff.swift +58 -0
  34. package/ios/cast/CastQueueReconciler.swift +228 -0
  35. package/ios/core/CastSessionManager.swift +215 -31
  36. package/ios/core/TrackPlayerCast.swift +96 -65
  37. package/ios/core/TrackPlayerCore.swift +31 -0
  38. package/ios/core/TrackPlayerListener.swift +19 -3
  39. package/ios/core/TrackPlayerPlayback.swift +12 -10
  40. package/ios/core/TrackPlayerQueue.swift +10 -2
  41. package/ios/core/TrackPlayerQueueBuild.swift +21 -9
  42. package/ios/core/TrackPlayerTempQueue.swift +27 -15
  43. package/ios/core/TrackPlayerUrlLoader.swift +6 -2
  44. package/ios/playlist/PlaylistManager.swift +60 -1
  45. package/ios/queue/HybridPlayerQueue.swift +40 -13
  46. package/lib/hooks/callbackManager.d.ts +2 -0
  47. package/lib/hooks/callbackManager.js +23 -8
  48. package/lib/index.js +5 -1
  49. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
  50. package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
  51. package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
  52. package/lib/types/PlayerQueue.d.ts +14 -0
  53. package/lib/utils/androidAutoMediaLibrary.d.ts +13 -1
  54. package/lib/utils/androidAutoMediaLibrary.js +23 -0
  55. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +6 -0
  56. package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
  57. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  58. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  59. package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string.hpp +76 -0
  60. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +29 -0
  61. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
  63. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
  64. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
  65. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
  66. package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void.kt +80 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string.kt +80 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_std__string.kt +80 -0
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +44 -0
  72. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
  73. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
  74. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
  75. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
  76. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
  77. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
  78. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
  79. package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
  80. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
  81. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
  82. package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
  83. package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
  84. package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
  85. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
  86. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
  87. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
  88. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
  89. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
  90. package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
  91. package/package.json +1 -1
  92. package/src/hooks/callbackManager.ts +25 -10
  93. package/src/index.ts +9 -1
  94. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
  95. package/src/specs/TrackPlayer.nitro.ts +11 -0
  96. package/src/types/AndroidAutoMediaLibrary.ts +24 -0
  97. package/src/types/PlayerQueue.ts +17 -0
  98. package/src/utils/androidAutoMediaLibrary.ts +48 -1
@@ -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 = self.castManager?.lastKnownRemotePosition ?? 0
70
- let remoteTrackId = self.castManager?.currentRemoteTrackId
75
+ let resumePosition = snapshot.position
76
+ let remoteTrackId = snapshot.trackId
71
77
  self.isCasting = false
72
78
 
73
- let index = self.currentTrackIndex
74
- guard index >= 0 && index < self.currentTracks.count else { return }
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
- _ = self.rebuildQueueFromPlaylistIndex(index: index)
81
-
82
- if !savedPlayNext.isEmpty || !savedUpNext.isEmpty {
83
- self.playNextStack = savedPlayNext
84
- self.upNextQueue = savedUpNext
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
- // If the receiver was playing the head temp track, make it current again
90
- // (mirrors skipToNext: remove from its list before it becomes current)
91
- if let target = remoteTrackId, target != self.player?.currentItem?.trackId {
92
- if self.playNextStack.first?.id == target {
93
- self.playNextStack.removeFirst()
94
- self.player?.advanceToNextItem()
95
- self.currentTemporaryType = .playNext
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
- /// Atomically (re)load the receiver queue from `index` in the actual queue (lazy targets stop the receiver and wait for updateTracks).
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 !playable.isEmpty else {
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 effectivePosition = playable[0].id == slice[0].id ? position : 0
197
+ let startTrack = built.tracks[built.startIndex]
198
+ let effectivePosition = startTrack.id == queue[index].id ? position : 0
146
199
 
147
- castManager?.setExpectedCurrentTrack(playable[0].id)
200
+ castManager?.setExpectedCurrentTrack(startTrack.id)
148
201
  castManager?.loadQueue(
149
- tracks: playable,
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
- /// Sync the receiver's upcoming items: append-only extensions don't interrupt playback; anything else reloads at the current position.
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
- let queue = getActualQueueInternal()
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
- if isCasting { castManager?.setQueueRepeatMode(mode) }
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
- if currentRepeatMode == .track {
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
- rebuildAVQueueFromCurrentPosition()
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
- rebuildAVQueueFromCurrentPosition()
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
- let targetIndex = currentTrackIndex < currentTracks.count
301
- ? currentTrackIndex : currentTracks.count - 1
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
- self.currentPlaylistId == playlistId,
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 update for currently playing track: \(track.id) (preserves gapless)")
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.currentTracks = updatedPlaylist.tracks
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] {