react-native-nitro-player 1.3.0 → 1.4.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -140,11 +140,14 @@ dependencies {
140
140
  implementation project(":react-native-nitro-modules")
141
141
 
142
142
  implementation "androidx.media3:media3-exoplayer:$media3_version"
143
+ implementation "androidx.media3:media3-exoplayer-hls:$media3_version" //for .m3u8
144
+ implementation "androidx.media3:media3-exoplayer-dash:$media3_version"
145
+ implementation "androidx.media3:media3-exoplayer-smoothstreaming:$media3_version"
143
146
  implementation "androidx.media3:media3-session:$media3_version"
144
147
  implementation "androidx.media3:media3-common:$media3_version"
145
148
  implementation "androidx.media:media:1.7.0"
146
-
147
- // WorkManager for background downloads
148
- implementation "androidx.work:work-runtime-ktx:2.9.0"
149
+
150
+ // WorkManager for background downloads.
151
+ implementation "androidx.work:work-runtime-ktx:2.10.5" // Latest version of 2.10
149
152
  }
150
153
 
@@ -125,6 +125,7 @@ class TrackPlayerCore private constructor(
125
125
 
126
126
  // ── Service binding ────────────────────────────────────────────────────
127
127
  private var serviceBound = false
128
+ private var rebindAttempts = 0
128
129
 
129
130
  private val serviceConnection =
130
131
  object : ServiceConnection {
@@ -132,7 +133,35 @@ class TrackPlayerCore private constructor(
132
133
  name: ComponentName?,
133
134
  service: IBinder?,
134
135
  ) {
135
- val binder = service as NitroPlayerPlaybackService.LocalBinder
136
+ // Android can redeliver the MediaSessionService binder instead of
137
+ // our LocalBinder (e.g. after the service is restarted). Guard the
138
+ // cast and rebind explicitly with ACTION_LOCAL_BIND instead of crashing.
139
+ val binder = service as? NitroPlayerPlaybackService.LocalBinder
140
+ if (binder == null) {
141
+ NitroPlayerLogger.log("TrackPlayerCore") {
142
+ "onServiceConnected received unexpected binder: $service"
143
+ }
144
+ try {
145
+ context.unbindService(this)
146
+ } catch (_: Exception) {}
147
+ serviceBound = false
148
+ if (rebindAttempts < 3) {
149
+ rebindAttempts++
150
+ handler.post {
151
+ val bindIntent =
152
+ Intent(context, NitroPlayerPlaybackService::class.java).apply {
153
+ action = NitroPlayerPlaybackService.ACTION_LOCAL_BIND
154
+ }
155
+ context.bindService(
156
+ bindIntent,
157
+ this,
158
+ Context.BIND_AUTO_CREATE,
159
+ )
160
+ }
161
+ }
162
+ return
163
+ }
164
+ rebindAttempts = 0
136
165
  playerHandler = binder.handler
137
166
  binder.service.trackPlayerCore = this@TrackPlayerCore
138
167
  serviceBound = true
@@ -11,7 +11,9 @@ import androidx.work.ForegroundInfo
11
11
  import androidx.work.WorkerParameters
12
12
  import com.margelo.nitro.nitroplayer.*
13
13
  import kotlinx.coroutines.Dispatchers
14
+ import kotlinx.coroutines.TimeoutCancellationException
14
15
  import kotlinx.coroutines.withContext
16
+ import kotlinx.coroutines.withTimeout
15
17
  import java.io.BufferedInputStream
16
18
  import java.io.FileOutputStream
17
19
  import java.net.HttpURLConnection
@@ -35,6 +37,14 @@ class DownloadWorker(
35
37
  private const val NOTIFICATION_CHANNEL_ID = "nitro_player_downloads"
36
38
  private const val BASE_NOTIFICATION_ID = 2001
37
39
  private const val BUFFER_SIZE = 8192
40
+
41
+ /**
42
+ * Hard upper bound on a single download. Bounds runaway/trickling
43
+ * downloads so the dataSync foreground service is always released
44
+ * well within the Android 14+ FGS timeout window — otherwise the
45
+ * system kills the app with ForegroundServiceDidNotStopInTimeException.
46
+ */
47
+ private const val MAX_DOWNLOAD_DURATION_MS = 30L * 60L * 1000L
38
48
  private val CONTENT_DISPOSITION_REGEX = Regex("filename=\"?([^\";]+)\"?")
39
49
  }
40
50
 
@@ -72,8 +82,12 @@ class DownloadWorker(
72
82
  // Download continues in background.
73
83
  }
74
84
 
75
- // Perform download
76
- val localPath = downloadFile(downloadId, trackId, trackTitle, urlString, storageLocation)
85
+ // Perform download, bounded so the foreground service is
86
+ // always released within the Android 14+ FGS timeout window.
87
+ val localPath =
88
+ withTimeout(MAX_DOWNLOAD_DURATION_MS) {
89
+ downloadFile(downloadId, trackId, trackTitle, urlString, storageLocation)
90
+ }
77
91
 
78
92
  if (localPath != null) {
79
93
  downloadManager.onComplete(downloadId, trackId, localPath)
@@ -91,6 +105,17 @@ class DownloadWorker(
91
105
  showErrorNotification(trackTitle)
92
106
  Result.retry()
93
107
  }
108
+ } catch (e: TimeoutCancellationException) {
109
+ val error =
110
+ DownloadError(
111
+ code = "DOWNLOAD_TIMEOUT",
112
+ message = "Download exceeded maximum allowed duration",
113
+ reason = DownloadErrorReason.TIMEOUT,
114
+ isRetryable = true,
115
+ )
116
+ downloadManager.onError(downloadId, trackId, error)
117
+ showErrorNotification(trackTitle)
118
+ Result.retry()
94
119
  } catch (e: Exception) {
95
120
  val errorReason =
96
121
  when {
@@ -7,6 +7,7 @@
7
7
  import AVFoundation
8
8
  import Foundation
9
9
  import MediaPlayer
10
+ import Network
10
11
  import NitroModules
11
12
  import ObjectiveC
12
13
 
@@ -25,6 +26,9 @@ class TrackPlayerCore: NSObject {
25
26
  static let preferredForwardBufferDuration: Double = 30.0
26
27
  static let preloadAssetKeys: [String] = ["playable", "duration", "tracks", "preferredTransform"]
27
28
  static let gaplessPreloadCount: Int = 3
29
+ // Stall & failure recovery
30
+ static let maxFailedItemRetries: Int = 3
31
+ static let failedItemRetryDelay: TimeInterval = 2.0
28
32
  }
29
33
 
30
34
  // MARK: - Thread infrastructure
@@ -53,6 +57,32 @@ class TrackPlayerCore: NSObject {
53
57
  internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
54
58
  internal var didRequestUrlsForCurrentItem = false
55
59
 
60
+ // MARK: - Stall & network recovery
61
+ // Whether the user/app wants playback ongoing (true after play(), false after pause()).
62
+ internal var intendedToPlay = false
63
+ // Set when AVPlayer stalls on a buffer underrun; cleared once the buffer refills.
64
+ // Needed because automaticallyWaitsToMinimizeStalling == false means AVPlayer
65
+ // will NOT auto-resume after a stall — we must re-issue play() ourselves.
66
+ internal var isRecoveringFromStall = false
67
+ // True while an audio-session interruption (phone call, Siri, other app) is active.
68
+ // Recovery must NOT resume playback during this window even though `intendedToPlay`
69
+ // may still be true — the system decides whether to resume when the interruption ends.
70
+ internal var isInterrupted = false
71
+ // Set right before `recoverFailedItem` swaps the current item in place. Tells the
72
+ // resulting `currentItemDidChange` that this is an in-place recovery of the SAME
73
+ // track, not a real track change — so it must not emit onChangeTrack or reset
74
+ // per-track state. Consumed (cleared) by the next `currentItemDidChange`.
75
+ internal var suppressTrackChangeEmit = false
76
+ // Last observed playback position, used to resume after recreating a failed item.
77
+ internal var lastKnownPosition: Double = 0
78
+ // Per-track retry budget for recreating AVPlayerItems that hit status == .failed.
79
+ internal var failedItemRetryCounts: [String: Int] = [:]
80
+ // Monitors network path changes (VPN toggle, Wi-Fi band switch) to drive recovery.
81
+ internal var pathMonitor: NWPathMonitor?
82
+ // Starts unsatisfied so the first genuine `.satisfied` update is treated as a
83
+ // transition (and so a network that starts down is not mistaken for "up").
84
+ internal var lastPathStatus: NWPath.Status = .unsatisfied
85
+
56
86
  // MARK: - Temporary queue
57
87
  internal var playNextStack: [TrackItem] = []
58
88
  internal var upNextQueue: [TrackItem] = []
@@ -182,7 +212,10 @@ class TrackPlayerCore: NSObject {
182
212
  p.removeObserver(self, forKeyPath: "currentItem")
183
213
  }
184
214
  NotificationCenter.default.removeObserver(self)
215
+ self.pathMonitor?.cancel()
216
+ self.pathMonitor = nil
185
217
  self.preloadedAssets.removeAll()
218
+ self.failedItemRetryCounts.removeAll()
186
219
  }
187
220
  }
188
221
 
@@ -49,6 +49,8 @@ extension TrackPlayerCore {
49
49
  }
50
50
 
51
51
  setupPlayerObservers()
52
+ setupAudioSessionObservers()
53
+ startNetworkMonitoring()
52
54
  }
53
55
 
54
56
  func setupPlayerObservers() {
@@ -67,6 +69,8 @@ extension TrackPlayerCore {
67
69
  name: .AVPlayerItemNewErrorLogEntry, object: nil)
68
70
  NotificationCenter.default.addObserver(self, selector: #selector(playerItemTimeJumped(_:)),
69
71
  name: .AVPlayerItemTimeJumped, object: nil)
72
+ NotificationCenter.default.addObserver(self, selector: #selector(playerItemPlaybackStalled(_:)),
73
+ name: .AVPlayerItemPlaybackStalled, object: nil)
70
74
  }
71
75
 
72
76
  func setupBoundaryTimeObserver() {
@@ -109,6 +113,9 @@ extension TrackPlayerCore {
109
113
  let rawDuration = currentItem.duration.seconds
110
114
  let duration = (rawDuration > 0 && !rawDuration.isNaN && !rawDuration.isInfinite) ? rawDuration : 0.0
111
115
 
116
+ // Remember the position so we can resume here if the item has to be recreated.
117
+ if position.isFinite && position >= 0 { lastKnownPosition = position }
118
+
112
119
  NitroPlayerLogger.log("TrackPlayerCore", "⏱️ Boundary crossed - position: \(Int(position))s / duration: \(duration)s")
113
120
 
114
121
  notifyPlaybackProgress(position, duration, isManuallySeeked ? true : nil)
@@ -222,6 +229,37 @@ extension TrackPlayerCore {
222
229
  }
223
230
  }
224
231
 
232
+ /// Fires when playback stalls because media did not arrive in time (buffer underrun).
233
+ /// AVQueuePlayer runs with `automaticallyWaitsToMinimizeStalling = false` during playback
234
+ /// (see `setupCurrentItemObservers`) so it will NOT resume on its own — we mark a recovery
235
+ /// flag and re-issue play() once `isPlaybackLikelyToKeepUp` flips back to true.
236
+ @objc func playerItemPlaybackStalled(_ notification: Notification) {
237
+ playerQueue.async { [weak self] in
238
+ guard let self, let player = self.player, let currentItem = player.currentItem else { return }
239
+ guard (notification.object as? AVPlayerItem) === currentItem else { return }
240
+ // A stall only matters if we actually want playback ongoing. If the user has
241
+ // paused (or a stall notification is racing a pause), do NOT arm the recovery
242
+ // flag — the `isPlaybackLikelyToKeepUp` observer fires only on change, so a flag
243
+ // set here while paused could never be cleared and would wedge state on .buffering.
244
+ guard self.intendedToPlay else { return }
245
+ self.isRecoveringFromStall = true
246
+ // The buffer may have already refilled before this notification was
247
+ // processed. `isPlaybackLikelyToKeepUp` fires only on change, so its
248
+ // observer would not fire again — resume here instead of waiting forever.
249
+ if currentItem.isPlaybackLikelyToKeepUp,
250
+ self.intendedToPlay, player.timeControlStatus != .playing {
251
+ NitroPlayerLogger.log("TrackPlayerCore",
252
+ "⚠️ Playback stalled but buffer already refilled — resuming now")
253
+ self.isRecoveringFromStall = false
254
+ player.playImmediately(atRate: Float(self.currentPlaybackSpeed))
255
+ } else {
256
+ NitroPlayerLogger.log("TrackPlayerCore",
257
+ "⚠️ Playback stalled (buffer underrun) — will auto-resume once the buffer refills")
258
+ }
259
+ self.emitStateChange()
260
+ }
261
+ }
262
+
225
263
  func currentItemDidChange() {
226
264
  // Clear old item observers
227
265
  currentItemObservers.removeAll()
@@ -229,6 +267,22 @@ extension TrackPlayerCore {
229
267
  // Reset proactive URL check debounce for the new track
230
268
  didRequestUrlsForCurrentItem = false
231
269
 
270
+ // A recovery replace (recoverFailedItem) swaps the current item in place for the
271
+ // SAME track. It must not look like a track change: don't emit onChangeTrack and
272
+ // don't reset per-track state that recovery still depends on.
273
+ let isRecoveryReplace = suppressTrackChangeEmit
274
+ suppressTrackChangeEmit = false
275
+
276
+ if !isRecoveryReplace {
277
+ // Genuine new track — reset per-track state.
278
+ // Resume position is per-track; recovery captured what it needs before the swap.
279
+ lastKnownPosition = 0
280
+ // A new track is not a stall recovery — drop any stale flag from the old item.
281
+ isRecoveringFromStall = false
282
+ // Fresh failure-retry budget for the new track.
283
+ failedItemRetryCounts.removeAll()
284
+ }
285
+
232
286
  guard let player, let currentItem = player.currentItem else {
233
287
  NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Current item changed to nil")
234
288
  // Queue exhausted — handle PLAYLIST repeat
@@ -304,9 +358,14 @@ extension TrackPlayerCore {
304
358
  if currentTemporaryType == .playNext { tempTrack = playNextStack.first(where: { $0.id == trackId }) }
305
359
  else if currentTemporaryType == .upNext { tempTrack = upNextQueue.first(where: { $0.id == trackId }) }
306
360
  if let track = tempTrack {
307
- NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Temporary track: \(track.title) - \(track.artist)")
308
- NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack for temporary track")
309
- notifyTrackChange(track, .skip)
361
+ if isRecoveryReplace {
362
+ NitroPlayerLogger.log("TrackPlayerCore",
363
+ " ♻️ Recovery replace of temporary track '\(track.title)' — suppressing onChangeTrack")
364
+ } else {
365
+ NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Temporary track: \(track.title) - \(track.artist)")
366
+ NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack for temporary track")
367
+ notifyTrackChange(track, .skip)
368
+ }
310
369
  }
311
370
  } else if let index = currentTracks.firstIndex(where: { $0.id == trackId }) {
312
371
  NitroPlayerLogger.log("TrackPlayerCore", " ✅ Found track at index: \(index)")
@@ -352,6 +411,11 @@ extension TrackPlayerCore {
352
411
  self?.playerQueue.async {
353
412
  if item.status == .readyToPlay {
354
413
  NitroPlayerLogger.log("TrackPlayerCore", "✅ Item ready, setting up boundaries")
414
+ // Note: the failure-retry budget is intentionally NOT cleared here. Reaching
415
+ // .readyToPlay momentarily must not refund retries — otherwise an item that
416
+ // flaps ready↔failed would bypass `maxFailedItemRetries`. The budget is reset
417
+ // only on a genuine track change (currentItemDidChange) or a real network
418
+ // restore (handleNetworkRestored).
355
419
  self?.setupBoundaryTimeObserver()
356
420
  // First item is buffered and ready — disable stall waiting for gapless inter-track transitions
357
421
  self?.player?.automaticallyWaitsToMinimizeStalling = false
@@ -365,8 +429,9 @@ extension TrackPlayerCore {
365
429
  }
366
430
  }
367
431
  } else if item.status == .failed {
368
- NitroPlayerLogger.log("TrackPlayerCore", "❌ Item failed")
369
- self?.notifyPlaybackStateChange(.stopped, .error)
432
+ NitroPlayerLogger.log("TrackPlayerCore",
433
+ "❌ Item failed — \(item.error?.localizedDescription ?? "unknown error")")
434
+ self?.recoverFailedItem(item)
370
435
  }
371
436
  }
372
437
  }
@@ -381,9 +446,21 @@ extension TrackPlayerCore {
381
446
  currentItemObservers.append(bufferEmptyObserver)
382
447
 
383
448
  let bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in
384
- if item.isPlaybackLikelyToKeepUp {
449
+ guard item.isPlaybackLikelyToKeepUp else { return }
450
+ self?.playerQueue.async {
451
+ guard let self, let player = self.player else { return }
452
+ guard player.currentItem === item else { return }
385
453
  NitroPlayerLogger.log("TrackPlayerCore", "▶️ Buffer likely to keep up")
386
- self?.emitStateChange()
454
+ // Recover from a stall: AVPlayer will NOT auto-resume because
455
+ // automaticallyWaitsToMinimizeStalling is false — re-issue play() ourselves.
456
+ if self.isRecoveringFromStall {
457
+ self.isRecoveringFromStall = false
458
+ if self.intendedToPlay && player.timeControlStatus != .playing {
459
+ NitroPlayerLogger.log("TrackPlayerCore", "🔁 Buffer refilled — resuming playback after stall")
460
+ player.playImmediately(atRate: Float(self.currentPlaybackSpeed))
461
+ }
462
+ }
463
+ self.emitStateChange()
387
464
  }
388
465
  }
389
466
  currentItemObservers.append(bufferKeepUpObserver)
@@ -396,6 +473,10 @@ extension TrackPlayerCore {
396
473
  state = .playing
397
474
  } else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
398
475
  state = .buffering
476
+ } else if isRecoveringFromStall {
477
+ // Stalled with automaticallyWaitsToMinimizeStalling == false: timeControlStatus
478
+ // reads .paused, but we are buffering, not user-paused — report it as such.
479
+ state = .buffering
399
480
  } else if player.rate == 0 {
400
481
  state = .paused
401
482
  } else {
@@ -86,6 +86,11 @@ extension TrackPlayerCore {
86
86
 
87
87
  func playInternal() {
88
88
  NitroPlayerLogger.log("TrackPlayerCore", "▶️ play() called")
89
+ self.intendedToPlay = true
90
+ // An explicit play() is the ground truth for intent — clear any stale
91
+ // interruption flag so a missed AVAudioSession `.ended` can't permanently
92
+ // wedge recovery off.
93
+ self.isInterrupted = false
89
94
  if let player = self.player {
90
95
  NitroPlayerLogger.log("TrackPlayerCore", "▶️ Player status: \(player.status.rawValue)")
91
96
  if let currentItem = player.currentItem {
@@ -105,6 +110,9 @@ extension TrackPlayerCore {
105
110
 
106
111
  func pauseInternal() {
107
112
  NitroPlayerLogger.log("TrackPlayerCore", "⏸️ pause() called")
113
+ // User-initiated pause — cancel any pending stall auto-resume.
114
+ self.intendedToPlay = false
115
+ self.isRecoveringFromStall = false
108
116
  self.player?.pause()
109
117
  playerQueue.asyncAfter(deadline: .now() + Constants.stateChangeDelay) { [weak self] in
110
118
  self?.emitStateChange()
@@ -164,6 +172,8 @@ extension TrackPlayerCore {
164
172
  queuePlayer.advanceToNextItem()
165
173
  } else {
166
174
  queuePlayer.pause()
175
+ self.intendedToPlay = false
176
+ self.isRecoveringFromStall = false
167
177
  self.notifyPlaybackStateChange(.stopped, .end)
168
178
  }
169
179
 
@@ -0,0 +1,220 @@
1
+ //
2
+ // TrackPlayerRecovery.swift
3
+ // NitroPlayer
4
+ //
5
+ // Created by Ritesh Shukla on 22/05/26.
6
+ //
7
+
8
+ import AVFoundation
9
+ import Foundation
10
+ import Network
11
+
12
+ extension TrackPlayerCore {
13
+
14
+ // MARK: - Failed-item recovery
15
+
16
+ /// Recreates a current AVPlayerItem that reached `status == .failed`.
17
+ /// A failed item cannot be revived, so we build a fresh one from the same
18
+ /// track URL, seek back to the last known position and resume if intended.
19
+ /// Must be called on `playerQueue`.
20
+ func recoverFailedItem(_ item: AVPlayerItem) {
21
+ guard let player else { return }
22
+ guard player.currentItem === item else {
23
+ NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Failed item is not the current item — ignoring")
24
+ return
25
+ }
26
+ guard let trackId = item.trackId, let track = findTrackById(trackId) else {
27
+ NitroPlayerLogger.log("TrackPlayerCore", "❌ Failed item has no recoverable track — stopping")
28
+ isRecoveringFromStall = false
29
+ // Nothing left to recover — playback is not happening; drop the intent so a
30
+ // later network restore doesn't try to resume a track we can't rebuild.
31
+ intendedToPlay = false
32
+ notifyPlaybackStateChange(.stopped, .error)
33
+ return
34
+ }
35
+
36
+ let attempts = (failedItemRetryCounts[trackId] ?? 0) + 1
37
+ guard attempts <= Constants.maxFailedItemRetries else {
38
+ NitroPlayerLogger.log("TrackPlayerCore",
39
+ "❌ '\(track.title)' failed \(attempts - 1)x — giving up")
40
+ failedItemRetryCounts.removeValue(forKey: trackId)
41
+ isRecoveringFromStall = false
42
+ // Don't silently resurrect a track the user already saw fail: clear the play
43
+ // intent so a later network-restore path doesn't auto-resume it.
44
+ intendedToPlay = false
45
+ notifyPlaybackStateChange(.stopped, .error)
46
+ return
47
+ }
48
+ failedItemRetryCounts[trackId] = attempts
49
+
50
+ // Resume from the furthest-known position: the failed item's own playhead is
51
+ // usually more current than the last periodic boundary-observer tick.
52
+ let itemTime = item.currentTime().seconds
53
+ let resumePosition = (itemTime.isFinite && itemTime > lastKnownPosition) ? itemTime : lastKnownPosition
54
+ // Keep lastKnownPosition aligned so a re-failure during recovery resumes here too.
55
+ lastKnownPosition = resumePosition
56
+
57
+ NitroPlayerLogger.log("TrackPlayerCore",
58
+ "🔁 Recreating failed item '\(track.title)' "
59
+ + "(attempt \(attempts)/\(Constants.maxFailedItemRetries), resume @ \(Int(resumePosition))s)")
60
+
61
+ // The failure may be a stale/expired streaming URL. Ask the JS layer for a fresh
62
+ // one; if it arrives before the retry fires, updateTracks replaces the item
63
+ // directly and the retry below aborts on the currentItem identity check.
64
+ notifyTracksNeedUpdate(tracks: [track], lookahead: lookaheadCount)
65
+
66
+ // A preloaded asset for this track shares the same dead connection state.
67
+ preloadedAssets.removeValue(forKey: trackId)
68
+ // Surface a buffering state while the retry is pending.
69
+ isRecoveringFromStall = true
70
+ emitStateChange()
71
+
72
+ playerQueue.asyncAfter(deadline: .now() + Constants.failedItemRetryDelay) { [weak self] in
73
+ guard let self, let player = self.player else { return }
74
+ // Bail if the user moved to a different track — or updateTracks already
75
+ // swapped in a fresh-URL item — while we waited.
76
+ guard player.currentItem === item else {
77
+ NitroPlayerLogger.log("TrackPlayerCore", "⏭️ Current item changed before retry — aborting recovery")
78
+ return
79
+ }
80
+ // Re-resolve the track so we pick up any URL refreshed during the wait.
81
+ guard let track = self.findTrackById(trackId),
82
+ let newItem = self.createGaplessPlayerItem(for: track, isPreload: false)
83
+ else {
84
+ NitroPlayerLogger.log("TrackPlayerCore", "❌ Could not rebuild item for track '\(trackId)'")
85
+ self.isRecoveringFromStall = false
86
+ self.intendedToPlay = false
87
+ self.notifyPlaybackStateChange(.stopped, .error)
88
+ return
89
+ }
90
+ // Mark the imminent currentItem change as an in-place recovery so the
91
+ // resulting currentItemDidChange does not emit a spurious onChangeTrack
92
+ // or reset per-track state (resume position, retry budget).
93
+ self.suppressTrackChangeEmit = true
94
+ player.replaceCurrentItem(with: newItem)
95
+
96
+ if resumePosition > 1 {
97
+ let time = CMTime(seconds: resumePosition, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
98
+ player.seek(to: time)
99
+ }
100
+ if self.intendedToPlay {
101
+ player.playImmediately(atRate: Float(self.currentPlaybackSpeed))
102
+ }
103
+ }
104
+ }
105
+
106
+ // MARK: - Network path monitoring
107
+
108
+ /// Starts watching the network path. VPN toggles and Wi-Fi band switches
109
+ /// produce a path update we use to recover a player left stuck by the change.
110
+ func startNetworkMonitoring() {
111
+ guard pathMonitor == nil else { return }
112
+ let monitor = NWPathMonitor()
113
+ monitor.pathUpdateHandler = { [weak self] path in
114
+ // Handler is delivered on `playerQueue` (passed to start(queue:)).
115
+ guard let self else { return }
116
+ let previous = self.lastPathStatus
117
+ self.lastPathStatus = path.status
118
+ NitroPlayerLogger.log("TrackPlayerCore",
119
+ "🌐 Network path update — status: \(path.status) (was: \(previous))")
120
+ // React only to a real down→up transition. A satisfied→satisfied update
121
+ // (e.g. Wi-Fi↔cellular handoff) is not an outage recovery.
122
+ if previous != .satisfied && path.status == .satisfied {
123
+ self.handleNetworkRestored()
124
+ }
125
+ }
126
+ monitor.start(queue: playerQueue)
127
+ pathMonitor = monitor
128
+ NitroPlayerLogger.log("TrackPlayerCore", "🌐 Network monitoring started")
129
+ }
130
+
131
+ /// Called on `playerQueue` on a genuine down→up network transition.
132
+ /// Heavily guarded so it is a no-op during healthy playback and never fights an
133
+ /// intentional pause (user pause, audio-session interruption, route loss).
134
+ func handleNetworkRestored() {
135
+ // `intendedToPlay` alone is not enough: an audio interruption pauses playback
136
+ // while leaving the intent set. Never resume into an active interruption.
137
+ guard intendedToPlay, !isInterrupted, let player, let currentItem = player.currentItem else { return }
138
+
139
+ // The connection died completely — the item is dead, recreate it.
140
+ if currentItem.status == .failed {
141
+ NitroPlayerLogger.log("TrackPlayerCore", "🌐 Network restored — recreating failed item")
142
+ // A genuine outage→restore earns the current track one fresh retry budget
143
+ // (only this track's count — not a blanket reset, which would let a flapping
144
+ // network defeat `maxFailedItemRetries`).
145
+ if let tid = currentItem.trackId { failedItemRetryCounts.removeValue(forKey: tid) }
146
+ recoverFailedItem(currentItem)
147
+ return
148
+ }
149
+
150
+ // The item survived but playback stalled — nudge it back to life.
151
+ if player.timeControlStatus != .playing {
152
+ NitroPlayerLogger.log("TrackPlayerCore", "🌐 Network restored — resuming stalled playback")
153
+ isRecoveringFromStall = true
154
+ player.playImmediately(atRate: Float(currentPlaybackSpeed))
155
+ }
156
+ }
157
+
158
+ // MARK: - Audio session handling
159
+
160
+ func setupAudioSessionObservers() {
161
+ let nc = NotificationCenter.default
162
+ nc.addObserver(self, selector: #selector(handleAudioSessionInterruption(_:)),
163
+ name: AVAudioSession.interruptionNotification, object: nil)
164
+ nc.addObserver(self, selector: #selector(handleAudioRouteChange(_:)),
165
+ name: AVAudioSession.routeChangeNotification, object: nil)
166
+ }
167
+
168
+ /// Phone calls, Siri, other apps, etc. interrupt the audio session. When the
169
+ /// interruption ends the session may be inactive — reactivate it and resume.
170
+ @objc func handleAudioSessionInterruption(_ notification: Notification) {
171
+ guard let info = notification.userInfo,
172
+ let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
173
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
174
+
175
+ switch type {
176
+ case .began:
177
+ NitroPlayerLogger.log("TrackPlayerCore", "🔇 Audio session interruption began")
178
+ // The system has paused playback. Flag the interruption so network/stall
179
+ // recovery does not fight it by resuming mid-interruption.
180
+ playerQueue.async { [weak self] in self?.isInterrupted = true }
181
+ case .ended:
182
+ let options: AVAudioSession.InterruptionOptions =
183
+ (info[AVAudioSessionInterruptionOptionKey] as? UInt).map { .init(rawValue: $0) } ?? []
184
+ let shouldResume = options.contains(.shouldResume)
185
+ NitroPlayerLogger.log("TrackPlayerCore",
186
+ "🔊 Audio session interruption ended (shouldResume: \(shouldResume))")
187
+ playerQueue.async { [weak self] in
188
+ guard let self else { return }
189
+ self.isInterrupted = false
190
+ do {
191
+ try AVAudioSession.sharedInstance().setActive(true)
192
+ } catch {
193
+ NitroPlayerLogger.log("TrackPlayerCore", "❌ Failed to reactivate audio session — \(error)")
194
+ }
195
+ if shouldResume && self.intendedToPlay {
196
+ self.player?.playImmediately(atRate: Float(self.currentPlaybackSpeed))
197
+ } else if !shouldResume {
198
+ // System says do not resume — treat playback as no longer intended so
199
+ // recovery won't silently restart it. The user must press play again.
200
+ self.intendedToPlay = false
201
+ }
202
+ }
203
+ @unknown default:
204
+ break
205
+ }
206
+ }
207
+
208
+ /// Pauses when the current output device disappears (e.g. headphones unplugged),
209
+ /// matching standard iOS playback behaviour.
210
+ @objc func handleAudioRouteChange(_ notification: Notification) {
211
+ guard let info = notification.userInfo,
212
+ let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
213
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
214
+
215
+ NitroPlayerLogger.log("TrackPlayerCore", "🎧 Audio route changed — reason: \(reason.rawValue)")
216
+ if reason == .oldDeviceUnavailable {
217
+ playerQueue.async { [weak self] in self?.pauseInternal() }
218
+ }
219
+ }
220
+ }
@@ -174,6 +174,10 @@ extension TrackPlayerCore {
174
174
 
175
175
  func findTrackById(_ trackId: String) -> TrackItem? {
176
176
  if let t = currentTracks.first(where: { $0.id == trackId }) { return t }
177
+ // Temporary-queue tracks need not exist in any playlist — search the temp
178
+ // stacks too so a failed playNext/upNext track is still recoverable.
179
+ if let t = playNextStack.first(where: { $0.id == trackId }) { return t }
180
+ if let t = upNextQueue.first(where: { $0.id == trackId }) { return t }
177
181
  for playlist in playlistManager.getAllPlaylists() {
178
182
  if let t = playlist.tracks.first(where: { $0.id == trackId }) { return t }
179
183
  }
@@ -37,17 +37,22 @@ extension TrackPlayerCore {
37
37
  let currentTrackIsEmpty = currentTrack.map {
38
38
  $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
39
39
  } ?? false
40
+ // The current track's player item has failed (e.g. an expired streaming URL).
41
+ // Unlike a healthy current track, a failed one MUST accept a fresh URL so that
42
+ // recoverFailedItem can rebuild it from updated track data.
43
+ let currentItemFailed = self.player?.currentItem?.status == .failed
44
+ && self.player?.currentItem?.trackId == currentTrackId
40
45
 
41
46
  let safeTracks = tracks.filter { track in
42
47
  switch true {
43
- case track.id == currentTrackId && !currentTrackIsEmpty:
48
+ case track.id == currentTrackId && (currentTrackIsEmpty || currentItemFailed):
49
+ NitroPlayerLogger.log("TrackPlayerCore",
50
+ "🔄 Updating current track (\(currentItemFailed ? "failed item" : "no URL")): \(track.id)")
51
+ return !track.url.isEmpty
52
+ case track.id == currentTrackId:
44
53
  NitroPlayerLogger.log("TrackPlayerCore",
45
54
  "⚠️ Skipping update for currently playing track: \(track.id) (preserves gapless)")
46
55
  return false
47
- case track.id == currentTrackId && currentTrackIsEmpty:
48
- NitroPlayerLogger.log("TrackPlayerCore",
49
- "🔄 Updating current track with no URL: \(track.id)")
50
- return !track.url.isEmpty
51
56
  case track.url.isEmpty:
52
57
  NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Skipping track with empty URL: \(track.id)")
53
58
  return false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-player",
3
- "version": "1.3.0",
3
+ "version": "1.4.1-alpha.0",
4
4
  "description": "A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",