react-native-nitro-player 1.4.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.
- package/ios/core/TrackPlayerCore.swift +33 -0
- package/ios/core/TrackPlayerListener.swift +88 -7
- package/ios/core/TrackPlayerPlayback.swift +10 -0
- package/ios/core/TrackPlayerRecovery.swift +220 -0
- package/ios/core/TrackPlayerTempQueue.swift +4 -0
- package/ios/core/TrackPlayerUrlLoader.swift +10 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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",
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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.4.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",
|