react-native-nitro-player 1.5.0 → 1.6.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  13. package/ios/HybridTrackPlayer.swift +58 -29
  14. package/ios/core/TrackPlayerCore.swift +22 -1
  15. package/ios/core/TrackPlayerListener.swift +84 -5
  16. package/ios/core/TrackPlayerNotify.swift +13 -7
  17. package/ios/core/TrackPlayerPlayback.swift +75 -39
  18. package/ios/core/TrackPlayerQueue.swift +28 -4
  19. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  20. package/ios/core/TrackPlayerRecovery.swift +1 -1
  21. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  22. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  23. package/ios/download/DownloadDatabase.swift +41 -13
  24. package/ios/media/MediaSessionManager.swift +19 -12
  25. package/ios/queue/HybridPlayerQueue.swift +10 -5
  26. package/lib/hooks/callbackManager.d.ts +27 -1
  27. package/lib/hooks/callbackManager.js +100 -1
  28. package/lib/hooks/index.d.ts +1 -0
  29. package/lib/hooks/index.js +1 -0
  30. package/lib/hooks/useActualQueue.js +18 -12
  31. package/lib/hooks/useNowPlaying.js +10 -2
  32. package/lib/hooks/useTimedMetadata.d.ts +16 -0
  33. package/lib/hooks/useTimedMetadata.js +21 -0
  34. package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
  35. package/lib/types/PlayerQueue.d.ts +7 -0
  36. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  37. package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
  38. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
  39. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  40. package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
  44. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  45. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
  46. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  47. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
  48. package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
  49. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  50. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  51. package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
  52. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  53. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  54. package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
  55. package/package.json +1 -1
  56. package/src/components/CastButton.tsx +14 -2
  57. package/src/hooks/callbackManager.ts +134 -2
  58. package/src/hooks/index.ts +1 -0
  59. package/src/hooks/useActualQueue.ts +18 -13
  60. package/src/hooks/useNowPlaying.ts +12 -2
  61. package/src/hooks/useTimedMetadata.ts +25 -0
  62. package/src/specs/TrackPlayer.nitro.ts +6 -0
  63. package/src/types/PlayerQueue.ts +8 -0
@@ -25,40 +25,63 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
25
25
  super.init()
26
26
  }
27
27
 
28
+ // MARK: - Ordered dispatch
29
+ //
30
+ // `Promise.async` starts an unordered Task, so the hop onto the player queue used
31
+ // to happen at an arbitrary later point: two calls made in order from JS could
32
+ // reach the player in reverse order (play-then-pause landing as pause-then-play).
33
+ // Enqueueing synchronously here — on the JS thread, at call time — makes the serial
34
+ // queue's FIFO order equal the JS call order.
35
+
36
+ private func enqueue<T>(_ block: @escaping () -> T) -> Promise<T> {
37
+ let promise = Promise<T>()
38
+ core.playerQueue.async { promise.resolve(withResult: block()) }
39
+ return promise
40
+ }
41
+
42
+ private func enqueueThrowing<T>(_ block: @escaping () throws -> T) -> Promise<T> {
43
+ let promise = Promise<T>()
44
+ core.playerQueue.async {
45
+ do { promise.resolve(withResult: try block()) }
46
+ catch { promise.reject(withError: error) }
47
+ }
48
+ return promise
49
+ }
50
+
28
51
  // MARK: - Playback Control (async Promise<Void>)
29
52
 
30
53
  func play() throws -> Promise<Void> {
31
- Promise.async { await self.core.play() }
54
+ enqueue { self.core.playOnQueue() }
32
55
  }
33
56
 
34
57
  func pause() throws -> Promise<Void> {
35
- Promise.async { await self.core.pause() }
58
+ enqueue { self.core.pauseOnQueue() }
36
59
  }
37
60
 
38
61
  func seek(position: Double) throws -> Promise<Void> {
39
- Promise.async { await self.core.seek(position: position) }
62
+ enqueue { self.core.seekOnQueue(position: position) }
40
63
  }
41
64
 
42
65
  func skipToNext() throws -> Promise<Void> {
43
- Promise.async { await self.core.skipToNext() }
66
+ enqueue { self.core.skipToNextOnQueue() }
44
67
  }
45
68
 
46
69
  func skipToPrevious() throws -> Promise<Void> {
47
- Promise.async { await self.core.skipToPrevious() }
70
+ enqueue { self.core.skipToPreviousOnQueue() }
48
71
  }
49
72
 
50
73
  func playSong(songId: String, fromPlaylist: String?) throws -> Promise<Void> {
51
- Promise.async { await self.core.playSong(songId: songId, fromPlaylist: fromPlaylist) }
74
+ enqueue { self.core.playSongInternal(songId: songId, fromPlaylist: fromPlaylist) }
52
75
  }
53
76
 
54
77
  func skipToIndex(index: Double) throws -> Promise<Bool> {
55
- Promise.async { await self.core.skipToIndex(index: Int(index)) }
78
+ enqueue { self.core.skipToIndexOnQueue(index: Int(index)) }
56
79
  }
57
80
 
58
81
  // MARK: - Repeat / Volume / Config
59
82
 
60
83
  func setRepeatMode(mode: RepeatMode) throws -> Promise<Void> {
61
- Promise.async { await self.core.setRepeatMode(mode: mode) }
84
+ enqueue { self.core.setRepeatModeOnQueue(mode: mode) }
62
85
  }
63
86
 
64
87
  func getRepeatMode() throws -> RepeatMode {
@@ -66,12 +89,12 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
66
89
  }
67
90
 
68
91
  func setVolume(volume: Double) throws -> Promise<Void> {
69
- Promise.async { await self.core.setVolume(volume: volume) }
92
+ enqueue { self.core.setVolumeOnQueue(volume: volume) }
70
93
  }
71
94
 
72
95
  func configure(config: PlayerConfig) throws -> Promise<Void> {
73
- Promise.async {
74
- await self.core.configure(
96
+ enqueue {
97
+ self.core.configureOnQueue(
75
98
  androidAutoEnabled: config.androidAutoEnabled,
76
99
  carPlayEnabled: config.carPlayEnabled,
77
100
  showInNotification: config.showInNotification,
@@ -83,80 +106,80 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
83
106
  // MARK: - Queue / State reads
84
107
 
85
108
  func getActualQueue() throws -> Promise<[TrackItem]> {
86
- Promise.async { await self.core.getActualQueue() }
109
+ enqueue { self.core.getActualQueueInternal() }
87
110
  }
88
111
 
89
112
  func getState() throws -> Promise<PlayerState> {
90
- Promise.async { await self.core.getState() }
113
+ enqueue { self.core.getStateInternal() }
91
114
  }
92
115
 
93
116
  func getCurrentTrackIndex() throws -> Promise<Double> {
94
- Promise.async { Double(await self.core.getCurrentTrackIndex()) }
117
+ enqueue { Double(self.core.currentTrackIndex) }
95
118
  }
96
119
 
97
120
  // MARK: - URL updates / lazy loading
98
121
 
99
122
  func updateTracks(tracks: [TrackItem]) throws -> Promise<Void> {
100
- Promise.async { await self.core.updateTracks(tracks: tracks) }
123
+ enqueue { self.core.updateTracksInternal(tracks: tracks) }
101
124
  }
102
125
 
103
126
  func getTracksById(trackIds: [String]) throws -> Promise<[TrackItem]> {
104
- Promise.async { await self.core.getTracksById(trackIds: trackIds) }
127
+ enqueue { self.core.getPlaylistManager().getTracksById(trackIds: trackIds) }
105
128
  }
106
129
 
107
130
  func getTracksNeedingUrls() throws -> Promise<[TrackItem]> {
108
- Promise.async { await self.core.getTracksNeedingUrls() }
131
+ enqueue { self.core.getTracksNeedingUrlsInternal() }
109
132
  }
110
133
 
111
134
  func getNextTracks(count: Double) throws -> Promise<[TrackItem]> {
112
- Promise.async { await self.core.getNextTracks(count: Int(count)) }
135
+ enqueue { self.core.getNextTracksInternal(count: Int(count)) }
113
136
  }
114
137
 
115
138
  // MARK: - Playback speed
116
139
  func setPlaybackSpeed(speed: Double) throws -> Promise<Void> {
117
- Promise.async { await self.core.setPlaybackSpeed(speed) }
140
+ enqueue { self.core.setPlaybackSpeedOnQueue(speed) }
118
141
  }
119
142
 
120
143
  func getPlaybackSpeed() throws -> Promise<Double> {
121
- Promise.async { await self.core.getPlaybackSpeed() }
144
+ enqueue { self.core.currentPlaybackSpeed }
122
145
  }
123
146
 
124
147
  // MARK: - Temporary queue v2
125
148
 
126
149
  func addToUpNext(trackId: String) throws -> Promise<Void> {
127
- Promise.async { try await self.core.addToUpNext(trackId: trackId) }
150
+ enqueueThrowing { try self.core.addToUpNextOnQueue(trackId: trackId) }
128
151
  }
129
152
 
130
153
  func playNext(trackId: String) throws -> Promise<Void> {
131
- Promise.async { try await self.core.playNext(trackId: trackId) }
154
+ enqueueThrowing { try self.core.playNextOnQueue(trackId: trackId) }
132
155
  }
133
156
 
134
157
  func removeFromPlayNext(trackId: String) throws -> Promise<Bool> {
135
- Promise.async { await self.core.removeFromPlayNext(trackId: trackId) }
158
+ enqueue { self.core.removeFromPlayNextOnQueue(trackId: trackId) }
136
159
  }
137
160
 
138
161
  func removeFromUpNext(trackId: String) throws -> Promise<Bool> {
139
- Promise.async { await self.core.removeFromUpNext(trackId: trackId) }
162
+ enqueue { self.core.removeFromUpNextOnQueue(trackId: trackId) }
140
163
  }
141
164
 
142
165
  func clearPlayNext() throws -> Promise<Void> {
143
- Promise.async { await self.core.clearPlayNext() }
166
+ enqueue { self.core.clearPlayNextOnQueue() }
144
167
  }
145
168
 
146
169
  func clearUpNext() throws -> Promise<Void> {
147
- Promise.async { await self.core.clearUpNext() }
170
+ enqueue { self.core.clearUpNextOnQueue() }
148
171
  }
149
172
 
150
173
  func reorderTemporaryTrack(trackId: String, newIndex: Double) throws -> Promise<Bool> {
151
- Promise.async { await self.core.reorderTemporaryTrack(trackId: trackId, newIndex: Int(newIndex)) }
174
+ enqueue { self.core.reorderTemporaryTrackOnQueue(trackId: trackId, newIndex: Int(newIndex)) }
152
175
  }
153
176
 
154
177
  func getPlayNextQueue() throws -> Promise<[TrackItem]> {
155
- Promise.async { await self.core.getPlayNextQueue() }
178
+ enqueue { self.core.playNextStack }
156
179
  }
157
180
 
158
181
  func getUpNextQueue() throws -> Promise<[TrackItem]> {
159
- Promise.async { await self.core.getUpNextQueue() }
182
+ enqueue { self.core.upNextQueue }
160
183
  }
161
184
 
162
185
  // MARK: - Android Auto (iOS no-op)
@@ -196,6 +219,11 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
196
219
  listenerIds.append(("onTracksNeedUpdate", id))
197
220
  }
198
221
 
222
+ func onTimedMetadata(callback: @escaping (_ metadata: TimedMetadata) -> Void) throws {
223
+ let id = core.addOnTimedMetadataListener(callback)
224
+ listenerIds.append(("onTimedMetadata", id))
225
+ }
226
+
199
227
  func onTemporaryQueueChange(callback: @escaping (_ playNextQueue: [TrackItem], _ upNextQueue: [TrackItem]) -> Void) throws {
200
228
  let id = core.addOnTemporaryQueueChangeListener(callback)
201
229
  listenerIds.append(("onTemporaryQueueChange", id))
@@ -212,6 +240,7 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
212
240
  case "onPlaybackProgressChange":_ = core.removeOnProgressListener(id: id)
213
241
  case "onTracksNeedUpdate": _ = core.removeOnTracksNeedUpdateListener(id: id)
214
242
  case "onTemporaryQueueChange": _ = core.removeOnTemporaryQueueChangeListener(id: id)
243
+ case "onTimedMetadata": _ = core.removeOnTimedMetadataListener(id: id)
215
244
  default: break
216
245
  }
217
246
  }
@@ -26,6 +26,10 @@ class TrackPlayerCore: NSObject {
26
26
  static let preferredForwardBufferDuration: Double = 30.0
27
27
  static let preloadAssetKeys: [String] = ["playable", "duration", "tracks", "preferredTransform"]
28
28
  static let gaplessPreloadCount: Int = 3
29
+ /// Upcoming AVPlayerItems kept materialized behind the current one. The logical
30
+ /// queue (currentTracks + temp lists) stays complete; only the AVQueuePlayer is
31
+ /// windowed, and it is topped up on every item transition.
32
+ static let queueWindowSize: Int = 4
29
33
  // Stall & failure recovery
30
34
  static let maxFailedItemRetries: Int = 3
31
35
  static let failedItemRetryDelay: TimeInterval = 2.0
@@ -44,7 +48,10 @@ class TrackPlayerCore: NSObject {
44
48
  internal var currentPlaylistId: String?
45
49
  internal var currentTrackIndex: Int = -1
46
50
  internal var currentTracks: [TrackItem] = []
47
- internal var pendingPlaylistUpdateWorkItem: DispatchWorkItem?
51
+ // Bumped on every updatePlaylist request (playerQueue-owned). A queued rebuild
52
+ // whose generation is stale was superseded by a later request and is dropped,
53
+ // so a burst of playlist mutations collapses into a single rebuild.
54
+ internal var playlistUpdateGeneration: UInt64 = 0
48
55
  internal var isManuallySeeked = false
49
56
  internal var currentRepeatMode: RepeatMode = .off
50
57
  internal var currentPlaybackSpeed: Double = 1.0
@@ -52,6 +59,10 @@ class TrackPlayerCore: NSObject {
52
59
  internal var boundaryTimeObserver: Any?
53
60
  internal var currentItemObservers: [NSKeyValueObservation] = []
54
61
 
62
+ // In-stream (timed) metadata output, attached to the current item only.
63
+ internal var metadataOutput: AVPlayerItemMetadataOutput?
64
+ internal weak var metadataOutputItem: AVPlayerItem?
65
+
55
66
  // Gapless playback
56
67
  internal var preloadedAssets: [String: AVURLAsset] = [:]
57
68
  internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
@@ -103,6 +114,7 @@ class TrackPlayerCore: NSObject {
103
114
  internal let onProgressListeners = ListenerRegistry<(Double, Double, Bool?) -> Void>()
104
115
  internal let onTracksNeedUpdateListeners = ListenerRegistry<([TrackItem], Int) -> Void>()
105
116
  internal let onTemporaryQueueChangeListeners = ListenerRegistry<([TrackItem], [TrackItem]) -> Void>()
117
+ internal let onTimedMetadataListeners = ListenerRegistry<(TimedMetadata) -> Void>()
106
118
  internal let onCastStateChangeListeners = ListenerRegistry<(CastState, String?) -> Void>()
107
119
 
108
120
  // MARK: - Google Cast
@@ -209,6 +221,13 @@ class TrackPlayerCore: NSObject {
209
221
  onTemporaryQueueChangeListeners.remove(id: id)
210
222
  }
211
223
 
224
+ @discardableResult func addOnTimedMetadataListener(_ cb: @escaping (TimedMetadata) -> Void) -> Int64 {
225
+ onTimedMetadataListeners.add(cb)
226
+ }
227
+ @discardableResult func removeOnTimedMetadataListener(id: Int64) -> Bool {
228
+ onTimedMetadataListeners.remove(id: id)
229
+ }
230
+
212
231
  @discardableResult func addOnCastStateChangeListener(_ cb: @escaping (CastState, String?) -> Void) -> Int64 {
213
232
  onCastStateChangeListeners.add(cb)
214
233
  }
@@ -230,6 +249,7 @@ class TrackPlayerCore: NSObject {
230
249
  p.removeTimeObserver(obs)
231
250
  }
232
251
  self.currentItemObservers.removeAll()
252
+ self.detachMetadataOutput()
233
253
  if let p = self.player {
234
254
  p.removeObserver(self, forKeyPath: "status")
235
255
  p.removeObserver(self, forKeyPath: "rate")
@@ -239,6 +259,7 @@ class TrackPlayerCore: NSObject {
239
259
  NotificationCenter.default.removeObserver(self)
240
260
  self.pathMonitor?.cancel()
241
261
  self.pathMonitor = nil
262
+ for asset in self.preloadedAssets.values { asset.cancelLoading() }
242
263
  self.preloadedAssets.removeAll()
243
264
  self.failedItemRetryCounts.removeAll()
244
265
  self.redirectResolver.clear()
@@ -285,6 +285,7 @@ extension TrackPlayerCore {
285
285
 
286
286
  guard let player, let currentItem = player.currentItem else {
287
287
  NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Current item changed to nil")
288
+ detachMetadataOutput()
288
289
  // Queue exhausted — handle PLAYLIST repeat
289
290
  if currentRepeatMode == .playlist && !currentTracks.isEmpty, let player = self.player {
290
291
  NitroPlayerLogger.log("TrackPlayerCore", "🔁 PLAYLIST repeat — rebuilding original queue and restarting")
@@ -292,7 +293,8 @@ extension TrackPlayerCore {
292
293
  upNextQueue.removeAll()
293
294
  currentTemporaryType = .none
294
295
 
295
- let allItems = currentTracks.compactMap { createGaplessPlayerItem(for: $0, isPreload: false) }
296
+ let windowed = currentTracks.prefix(1 + Constants.queueWindowSize)
297
+ let allItems = windowed.compactMap { createGaplessPlayerItem(for: $0, isPreload: false) }
296
298
  var lastItem: AVPlayerItem? = nil
297
299
  for item in allItems {
298
300
  player.insert(item, after: lastItem)
@@ -399,14 +401,47 @@ extension TrackPlayerCore {
399
401
  setupBoundaryTimeObserver()
400
402
  }
401
403
 
404
+ // Top up the windowed AVQueuePlayer — the item we just consumed freed a slot.
405
+ // Only when it is actually short: rebuildAVQueueFromCurrentPosition can fall into
406
+ // rebuildQueueFromPlaylistIndex (current track no longer in the playlist), which
407
+ // replaces the current item and re-enters this callback. The length check makes any
408
+ // such re-entry a no-op and terminate.
409
+ if player.items().count - 1 < Constants.queueWindowSize {
410
+ rebuildAVQueueFromCurrentPosition()
411
+ }
412
+
402
413
  // Preload upcoming tracks for gapless playback
403
414
  preloadUpcomingTracks(from: currentTrackIndex + 1)
404
415
  cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
405
416
  }
406
417
 
418
+ /// Attaches an `AVPlayerItemMetadataOutput` to the current item so ICY/ID3
419
+ /// metadata emitted mid-stream reaches JS. Detaches from the previous item first —
420
+ /// an output may only be attached to one item at a time.
421
+ func attachMetadataOutput(to item: AVPlayerItem) {
422
+ detachMetadataOutput()
423
+ let output = AVPlayerItemMetadataOutput(identifiers: nil)
424
+ output.setDelegate(self, queue: playerQueue)
425
+ item.add(output)
426
+ metadataOutput = output
427
+ metadataOutputItem = item
428
+ }
429
+
430
+ /// Clears the metadata output so a detached one can no longer pass the identity
431
+ /// check in the delegate and emit metadata for an item that is no longer playing.
432
+ func detachMetadataOutput() {
433
+ if let output = metadataOutput, let item = metadataOutputItem {
434
+ item.remove(output)
435
+ }
436
+ metadataOutput = nil
437
+ metadataOutputItem = nil
438
+ }
439
+
407
440
  func setupCurrentItemObservers(item: AVPlayerItem) {
408
441
  NitroPlayerLogger.log("TrackPlayerCore", "📱 Setting up item observers")
409
442
 
443
+ attachMetadataOutput(to: item)
444
+
410
445
  let statusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
411
446
  self?.playerQueue.async {
412
447
  if item.status == .readyToPlay {
@@ -421,11 +456,12 @@ extension TrackPlayerCore {
421
456
  self?.player?.automaticallyWaitsToMinimizeStalling = false
422
457
  // Update now playing info now that duration is available (capture on playerQueue first)
423
458
  let state = self?.getStateInternal()
424
- let queue = self?.getActualQueueInternal() ?? []
459
+ let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
425
460
  let track = self?.getCurrentTrack()
426
461
  DispatchQueue.main.async {
427
462
  if let track = track, let state = state {
428
- self?.mediaSessionManager?.updateFromPlayerQueue(track: track, state: state, queue: queue)
463
+ self?.mediaSessionManager?.updateFromPlayerQueue(
464
+ track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
429
465
  }
430
466
  }
431
467
  } else if item.status == .failed {
@@ -438,9 +474,13 @@ extension TrackPlayerCore {
438
474
  currentItemObservers.append(statusObserver)
439
475
 
440
476
  let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in
441
- if item.isPlaybackBufferEmpty {
477
+ guard item.isPlaybackBufferEmpty else { return }
478
+ // KVO fires on an arbitrary thread — emitStateChange reads playerQueue-owned
479
+ // state (currentTracks, temp queues, isRecoveringFromStall), so hop first.
480
+ self?.playerQueue.async {
481
+ guard let self, self.player?.currentItem === item else { return }
442
482
  NitroPlayerLogger.log("TrackPlayerCore", "⏸️ Buffer empty (buffering)")
443
- self?.emitStateChange()
483
+ self.emitStateChange()
444
484
  }
445
485
  }
446
486
  currentItemObservers.append(bufferEmptyObserver)
@@ -466,6 +506,31 @@ extension TrackPlayerCore {
466
506
  currentItemObservers.append(bufferKeepUpObserver)
467
507
  }
468
508
 
509
+ func handleTimedMetadataGroups(_ groups: [AVTimedMetadataGroup]) {
510
+ var title: String?
511
+ var artist: String?
512
+ var album: String?
513
+
514
+ for group in groups {
515
+ for item in group.items {
516
+ guard let value = item.stringValue, !value.isEmpty else { continue }
517
+ switch item.commonKey {
518
+ case .some(.commonKeyTitle): title = value
519
+ case .some(.commonKeyArtist): artist = value
520
+ case .some(.commonKeyAlbumName): album = value
521
+ default:
522
+ // ICY/Shoutcast streams carry no common keys — match on the identifier
523
+ if item.identifier == .icyMetadataStreamTitle { title = value }
524
+ }
525
+ }
526
+ }
527
+
528
+ guard title != nil || artist != nil || album != nil else { return }
529
+ NitroPlayerLogger.log("TrackPlayerCore", "🏷️ Timed metadata - \(title ?? "-") / \(artist ?? "-")")
530
+ // artworkUrl is Android-only: iOS delivers embedded art as raw data, not a URL
531
+ notifyTimedMetadata(TimedMetadata(title: title, artist: artist, album: album, artworkUrl: nil))
532
+ }
533
+
469
534
  func emitStateChange(reason: Reason? = nil) {
470
535
  guard let player else { return }
471
536
  let state: TrackPlayerState
@@ -486,3 +551,17 @@ extension TrackPlayerCore {
486
551
  notifyPlaybackStateChange(state, reason)
487
552
  }
488
553
  }
554
+
555
+ extension TrackPlayerCore: AVPlayerItemMetadataOutputPushDelegate {
556
+ // Delivered on playerQueue (set in attachMetadataOutput)
557
+ func metadataOutput(
558
+ _ output: AVPlayerItemMetadataOutput,
559
+ didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup],
560
+ from track: AVPlayerItemTrack?
561
+ ) {
562
+ // A detached output can still flush metadata it had already buffered — ignore it,
563
+ // it belongs to an item that is no longer current.
564
+ guard output === metadataOutput else { return }
565
+ handleTimedMetadataGroups(groups)
566
+ }
567
+ }
@@ -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 queue = getActualQueueInternal()
19
+ let metrics = actualQueueMetrics()
19
20
  DispatchQueue.main.async { [weak self] in
20
- self?.mediaSessionManager?.updateFromPlayerQueue(track: track, state: state, queue: queue)
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 queue = getActualQueueInternal()
28
- let track = getCurrentTrack()
30
+ let metrics = actualQueueMetrics()
29
31
  DispatchQueue.main.async { [weak self] in
30
- guard let track = track else { return }
31
- self?.mediaSessionManager?.updateFromPlayerQueue(track: track, state: playerState, queue: queue)
32
+ self?.mediaSessionManager?.updateFromPlayerQueue(
33
+ track: track, state: playerState, queueCount: metrics.count, positionInQueue: metrics.position)
32
34
  }
33
35
  }
34
36
 
@@ -44,6 +46,10 @@ extension TrackPlayerCore {
44
46
  onTracksNeedUpdateListeners.forEach { $0(tracks, lookahead) }
45
47
  }
46
48
 
49
+ func notifyTimedMetadata(_ metadata: TimedMetadata) {
50
+ onTimedMetadataListeners.forEach { $0(metadata) }
51
+ }
52
+
47
53
  func notifyTemporaryQueueChange() {
48
54
  let pn = playNextStack
49
55
  let un = upNextQueue
@@ -11,78 +11,98 @@ import MediaPlayer
11
11
 
12
12
  extension TrackPlayerCore {
13
13
 
14
- func play() async {
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
- await withPlayerQueueNoThrow { self.playInternal() }
30
+ playInternal()
21
31
  }
32
+ func play() async { await withPlayerQueueNoThrow { self.playOnQueue() } }
22
33
 
23
- func pause() async {
34
+ func pauseOnQueue() {
24
35
  if isCasting {
25
36
  intendedToPlay = false
26
37
  castManager?.pause()
27
38
  return
28
39
  }
29
- await withPlayerQueueNoThrow { self.pauseInternal() }
40
+ pauseInternal()
30
41
  }
42
+ func pause() async { await withPlayerQueueNoThrow { self.pauseOnQueue() } }
31
43
 
32
- func seek(position: Double) async {
44
+ func seekOnQueue(position: Double) {
33
45
  if isCasting {
34
46
  castManager?.seek(to: position)
35
47
  return
36
48
  }
37
- await withPlayerQueueNoThrow { self.seekInternal(position: position) }
49
+ seekInternal(position: position)
50
+ }
51
+ func seek(position: Double) async {
52
+ await withPlayerQueueNoThrow { self.seekOnQueue(position: position) }
38
53
  }
39
54
 
40
- func skipToNext() async {
55
+ func skipToNextOnQueue() {
41
56
  if isCasting {
42
57
  castManager?.skipToNext()
43
58
  return
44
59
  }
45
- await withPlayerQueueNoThrow { self.skipToNextInternal() }
60
+ skipToNextInternal()
46
61
  }
62
+ func skipToNext() async { await withPlayerQueueNoThrow { self.skipToNextOnQueue() } }
47
63
 
48
- func skipToPrevious() async {
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
- await withPlayerQueueNoThrow { self.skipToPreviousCastInternal() }
67
+ skipToPreviousCastInternal()
52
68
  return
53
69
  }
54
- await withPlayerQueueNoThrow { self.skipToPreviousInternal() }
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 setVolume(volume: Double) async {
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
- await withPlayerQueueNoThrow {
73
- let clamped = max(0.0, min(100.0, volume))
74
- let normalized = Float(clamped / 100.0)
75
- self.player?.volume = normalized
76
- NitroPlayerLogger.log("TrackPlayerCore", "🔊 Volume set to \(Int(clamped))% (normalized: \(normalized))")
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 configure(androidAutoEnabled: Bool?, carPlayEnabled: Bool?, showInNotification: Bool?, lookaheadCount: Int?) async {
81
- await withPlayerQueueNoThrow {
82
- if let la = lookaheadCount {
83
- self.lookaheadCount = la
84
- NitroPlayerLogger.log("TrackPlayerCore", "🔄 Lookahead count set to: \(la)")
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 setPlaybackSpeed(_ speed: Double) async {
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
- await withPlayerQueueNoThrow {
103
- self.currentPlaybackSpeed = speed
104
- // Only update rate if currently playing; pause keeps rate at 0 until play() is called
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 {