react-native-queue-player 2.0.0 → 2.0.1

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 (28) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +9 -1
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +1 -1
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +10 -2
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +149 -8
  8. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +60 -0
  9. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
  10. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
  11. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +34 -0
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +86 -0
  13. package/ios/CrossfadeEngine.swift +12 -0
  14. package/ios/LookaheadCache.swift +27 -0
  15. package/ios/MediaResponseCheck.swift +122 -0
  16. package/ios/Tests/MediaResponseCheckTests.swift +128 -0
  17. package/ios/Tests/RetryRecoveryTests.swift +115 -0
  18. package/ios/Tests/TopUpWindowGateTests.swift +47 -0
  19. package/ios/Tests/TrackPlayerEndVerdictTests.swift +41 -0
  20. package/ios/TrackPlayer+EventsDispatch.swift +77 -3
  21. package/ios/TrackPlayer+Lifecycle.swift +53 -1
  22. package/ios/TrackPlayer+Queue.swift +11 -0
  23. package/ios/TrackPlayer+Recovery.swift +17 -0
  24. package/ios/TrackPlayer+Skip.swift +10 -0
  25. package/ios/TrackPlayer+Transport.swift +31 -26
  26. package/ios/TrackPlayer+Window.swift +32 -0
  27. package/ios/TrackPlayer.swift +26 -0
  28. package/package.json +1 -1
@@ -234,6 +234,121 @@ final class RetryRecoveryTests: XCTestCase {
234
234
  XCTAssertTrue(engine.seeks.isEmpty)
235
235
  }
236
236
 
237
+ // MARK: - The queue stops on a track whose failure was reported
238
+
239
+ /// A failure dispatched as its item is seated survives the first
240
+ /// announcement of that position, which happens in the same block.
241
+ func testAFailureAtSeatTimeStillHoldsTheQueueAfterTheTrackIsAnnounced() {
242
+ let player = TrackPlayer()
243
+ let engine = RetryEngineStub()
244
+ seed(player, engine: engine)
245
+ let qid = player.queueItemIds[0]
246
+ let item = AVPlayerItem(url: URL(string: "https://example.com/0.m4a")!)
247
+ item.queueItemId = qid
248
+
249
+ player.dispatchErrorOrRetry(
250
+ item: item,
251
+ underlying: NSError(domain: AVFoundationErrorDomain, code: -11828, userInfo: nil))
252
+ player.handleCurrentItemDidChange()
253
+
254
+ XCTAssertEqual(
255
+ player.reportedFailureOnCurrentTrack, qid,
256
+ "the track change announcing the failed track must not clear its report")
257
+ }
258
+
259
+ /// Announcing a different track clears a report the queue has moved past.
260
+ func testAnnouncingADifferentTrackClearsTheReport() {
261
+ let player = TrackPlayer()
262
+ let engine = RetryEngineStub()
263
+ seed(player, engine: engine)
264
+ player.reportedFailureQueueItemIds = [player.queueItemIds[0]]
265
+ player.currentTrackIndex = 1
266
+
267
+ player.dispatchTrackChange(reason: .userSkipNext)
268
+
269
+ XCTAssertTrue(player.reportedFailureQueueItemIds.isEmpty)
270
+ }
271
+
272
+ /// A forced reinstall builds the current position from scratch, so what was
273
+ /// reported against it no longer describes what is installed.
274
+ func testAForcedReinstallClearsTheReport() {
275
+ let player = TrackPlayer()
276
+ let engine = RetryEngineStub()
277
+ seed(player, engine: engine)
278
+ player.reportedFailureQueueItemIds = [player.queueItemIds[0]]
279
+
280
+ player.performingMutation { player.fullRebuildPlayerQueue(forcingReinstall: true) }
281
+
282
+ XCTAssertTrue(player.reportedFailureQueueItemIds.isEmpty)
283
+ }
284
+
285
+ /// A reconcile that is not rebuilding the position leaves the report alone —
286
+ /// the track it names is still the one that failed.
287
+ func testAPlainRebuildLeavesTheReportAlone() {
288
+ let player = TrackPlayer()
289
+ let engine = RetryEngineStub()
290
+ seed(player, engine: engine)
291
+ let qid = player.queueItemIds[0]
292
+ player.reportedFailureQueueItemIds = [qid]
293
+
294
+ player.performingMutation { player.fullRebuildPlayerQueue() }
295
+
296
+ XCTAssertEqual(player.reportedFailureQueueItemIds, [qid])
297
+ }
298
+
299
+ /// One `play()`, one error. Several tracks of an installed run fail at once —
300
+ /// a window of unplayable sources does exactly that — and only the track the
301
+ /// listener is on is theirs to act on; it reports once however many times it
302
+ /// or its neighbours fire. The record is a set so a repeat cannot be reopened
303
+ /// by a different track replacing it in a one-slot key.
304
+ func testOnlyTheCurrentTrackReportsAndOnlyOnce() {
305
+ let player = TrackPlayer()
306
+ let engine = RetryEngineStub()
307
+ seed(player, engine: engine)
308
+ var reported: [PlaybackError] = []
309
+ _ = player.errorListeners.add { reported.append($0) }
310
+ let fatal = NSError(domain: AVFoundationErrorDomain, code: -11828, userInfo: nil)
311
+ func item(at index: Int) -> AVPlayerItem {
312
+ let made = AVPlayerItem(url: URL(string: "https://example.com/\(index).m4a")!)
313
+ made.queueItemId = player.queueItemIds[index]
314
+ return made
315
+ }
316
+
317
+ // Two tracks failing, each reported twice, in the order that defeats a
318
+ // one-slot key: every fire names a different track than the one before it.
319
+ player.dispatchErrorOrRetry(item: item(at: 0), underlying: fatal)
320
+ player.dispatchErrorOrRetry(item: item(at: 1), underlying: fatal)
321
+ player.dispatchErrorOrRetry(item: item(at: 0), underlying: fatal)
322
+ player.dispatchErrorOrRetry(item: item(at: 1), underlying: fatal)
323
+
324
+ XCTAssertEqual(
325
+ reported.count, 1,
326
+ "one play(), one error: the track being played, once, whatever else fails")
327
+ XCTAssertEqual(
328
+ reported.first?.queueItemId, player.queueItemIds[0],
329
+ "the error names the track the listener is on")
330
+ XCTAssertEqual(
331
+ player.reportedFailureQueueItemIds, Set([player.queueItemIds[0]]),
332
+ "and only that track holds the queue")
333
+ }
334
+
335
+ /// Seating a position is an attempt at it, so a skip onto a track that
336
+ /// reported earlier clears its report. Without this the track is wedged:
337
+ /// it never reports again, its run is never extended, and it never reaches
338
+ /// an end verdict — silently, until play().
339
+ func testSkippingOntoAReportedTrackClearsItsReport() {
340
+ let player = TrackPlayer()
341
+ let engine = RetryEngineStub()
342
+ seed(player, engine: engine)
343
+ player.reportedFailureQueueItemIds = [player.queueItemIds[1]]
344
+
345
+ player.applyNewCurrentIndex(newIndex: 1, reason: .userSkipToIndex)
346
+
347
+ XCTAssertTrue(
348
+ player.reportedFailureQueueItemIds.isEmpty,
349
+ "the track being skipped onto is being tried again")
350
+ }
351
+
237
352
  private func seed(_ player: TrackPlayer, engine: RetryEngineStub) {
238
353
  _ = player.queueState.replaceAll((0 ..< 2).map {
239
354
  TrackItem(
@@ -279,6 +279,53 @@ final class TopUpWindowGateTests: XCTestCase {
279
279
  XCTAssertEqual(engine.seatedAtPosition, 0)
280
280
  }
281
281
 
282
+ /// The same run, the same owed top-up: only the current track's reported
283
+ /// failure differs.
284
+ func testAReportedFailureOnTheCurrentTrackIsWhatHoldsTheTopUpBack() async throws {
285
+ let engine = WindowEngineStub(count: 3)
286
+ try await seed(engine: engine, trackCount: 20)
287
+ await onQueue { $0.reportedFailureQueueItemIds = [$0.queueItemIds[$0.currentTrackIndex]] }
288
+
289
+ await onQueue { $0.topUpWindow() }
290
+ XCTAssertTrue(
291
+ engine.inserted.isEmpty,
292
+ "the track whose failure was reported is not installed again")
293
+
294
+ await onQueue { $0.reportedFailureQueueItemIds.removeAll() }
295
+ await onQueue { $0.topUpWindow() }
296
+ XCTAssertFalse(
297
+ engine.inserted.isEmpty,
298
+ "the same run extends once the track is no longer the reported failure")
299
+ }
300
+
301
+ /// The same run, the same owed top-up: only the scheduled attempt differs.
302
+ func testAScheduledAttemptForTheCurrentTrackAlsoHoldsTheTopUpBack() async throws {
303
+ let engine = WindowEngineStub(count: 3)
304
+ try await seed(engine: engine, trackCount: 20)
305
+ await onQueue { $0.pendingRetryQueueItemId = $0.queueItemIds[$0.currentTrackIndex] }
306
+
307
+ await onQueue { $0.topUpWindow() }
308
+ XCTAssertTrue(
309
+ engine.inserted.isEmpty,
310
+ "the attempt re-seats the track itself; rebuilding first fails it again")
311
+
312
+ await onQueue { $0.pendingRetryQueueItemId = nil }
313
+ await onQueue { $0.topUpWindow() }
314
+ XCTAssertFalse(engine.inserted.isEmpty)
315
+ }
316
+
317
+ /// A failure against a track the playhead has left does not stop the run
318
+ /// being extended.
319
+ func testATopUpRunsWhenTheReportedFailureIsADifferentTrack() async throws {
320
+ let engine = WindowEngineStub(count: 3)
321
+ try await seed(engine: engine, trackCount: 20)
322
+ await onQueue { $0.reportedFailureQueueItemIds = [$0.queueItemIds[7]] }
323
+
324
+ await onQueue { $0.topUpWindow() }
325
+
326
+ XCTAssertFalse(engine.inserted.isEmpty)
327
+ }
328
+
282
329
  // MARK: - Helpers
283
330
 
284
331
  private func seed(engine: WindowEngineStub, trackCount: Int) async throws {
@@ -134,6 +134,47 @@ final class TrackPlayerEndVerdictTests: XCTestCase {
134
134
  XCTAssertFalse(state.ended)
135
135
  }
136
136
 
137
+ // MARK: - A drain that is really a failure
138
+
139
+ /// A drain that follows the current track's reported failure stays where it
140
+ /// is rather than wrapping.
141
+ func testADrainAfterTheCurrentTrackFailedDoesNotAdvanceTheQueue() async throws {
142
+ try await seedQueue(count: 3, at: 2, repeatMode: .queue)
143
+ await onQueue { $0.reportedFailureQueueItemIds = [$0.queueItemIds[2]] }
144
+
145
+ await onQueue { $0.applyEndVerdict() }
146
+
147
+ let state = try await read { (index: $0.currentTrackIndex, ended: $0.reachedQueueEnd) }
148
+ XCTAssertEqual(state.index, 2, "the queue stays on the track whose failure was reported")
149
+ XCTAssertFalse(state.ended, "a reported failure is not the end of the queue either")
150
+ }
151
+
152
+ /// The crossfade engine's own end signal reaches the same decision.
153
+ func testAnEngineEndReportAfterTheCurrentTrackFailedDoesNotAdvanceTheQueue() async throws {
154
+ try await installCrossfade()
155
+ try await seedQueue(count: 3, at: 2, repeatMode: .queue)
156
+ await onQueue { $0.reportedFailureQueueItemIds = [$0.queueItemIds[2]] }
157
+ let engine = try await read { $0.engine }
158
+ let reporting = try XCTUnwrap(engine)
159
+
160
+ await onQueue { $0.enginePlaybackEnded(reporting) }
161
+
162
+ let index = try await read { $0.currentTrackIndex }
163
+ XCTAssertEqual(index, 2)
164
+ }
165
+
166
+ /// A report against a track the playhead has left does not hold a genuine
167
+ /// end of queue open.
168
+ func testADrainAfterADifferentTrackFailedStillEndsTheQueue() async throws {
169
+ try await seedQueue(count: 3, at: 2, repeatMode: .off)
170
+ await onQueue { $0.reportedFailureQueueItemIds = [$0.queueItemIds[0]] }
171
+
172
+ await onQueue { $0.applyEndVerdict() }
173
+
174
+ let ended = try await read { $0.reachedQueueEnd }
175
+ XCTAssertTrue(ended)
176
+ }
177
+
137
178
  // MARK: - Helpers
138
179
 
139
180
  private func installCrossfade() async throws {
@@ -87,14 +87,77 @@ extension TrackPlayer {
87
87
  self.pendingTrackChangeReason = nil
88
88
  let endFired = self.didPlayToEndPending
89
89
  self.didPlayToEndPending = false
90
- if endFired && !pendingUserMutation {
90
+ // A queue stopped on a reported failure does not re-derive its index from
91
+ // the player. `removeAllItems` fires `AVPlayerItemDidPlayToEndTime` for an
92
+ // item that never played (FB9221518), and the re-seat below issues one, so
93
+ // without this a later pass would resync off the parked position onto the
94
+ // track behind it — the move the stop exists to prevent. The report does
95
+ // not exist yet on the pass that files it, so that pass still runs.
96
+ if endFired && !pendingUserMutation && self.reportedFailureOnCurrentTrack == nil {
91
97
  if let derived = matchTrackIndex(forCurrentItem: self.player?.currentItem) {
92
- self.currentTrackIndex = derived
98
+ // One natural advance that lands more than one position on means the
99
+ // player dropped what it skipped: `AVQueuePlayer` discards an item that
100
+ // cannot be played, and a track that failed while the previous one was
101
+ // still playing is never seated, so nothing ever observes its failure —
102
+ // the current-item status observer is the only load-failure path and it
103
+ // never sees that item. Carrying the queue past it would skip a track
104
+ // the listener never heard and was never told about.
105
+ if derived > self.currentTrackIndex + 1 {
106
+ self.stopOnDroppedTrack(at: self.currentTrackIndex + 1)
107
+ } else {
108
+ self.currentTrackIndex = derived
109
+ }
93
110
  }
94
111
  }
95
112
  return reason
96
113
  }
97
114
 
115
+
116
+ /// Stop on a track the player dropped mid-advance, report it, and park.
117
+ ///
118
+ /// Nothing has observed this track fail — `AVQueuePlayer` removed it rather
119
+ /// than seating it — so the report is raised here, against the position.
120
+ /// `fileFormatNotRecognized` is chosen for being fatal, which is what the
121
+ /// stop needs: the same function drops the play intent, and doing that under
122
+ /// a transient code would contradict `dispatchErrorOrRetry`, which refuses to.
123
+ /// The library does not know *why* the player dropped it — a 404, a DNS
124
+ /// failure and a non-media body all land here — so the code is a statement
125
+ /// about recoverability, not about the body.
126
+ private func stopOnDroppedTrack(at index: Int) {
127
+ guard let qid = self.queueItemIds[safe: index] else { return }
128
+ self.currentTrackIndex = index
129
+ self.engine?.pause()
130
+ self.wantsToPlay = false
131
+ // The player discarded this position's item and is sitting on one further
132
+ // on, so every read that answers from the seated item — duration,
133
+ // seekability, the progress payload's position — would describe a track the
134
+ // listener is not on. Re-seat the failed position so the index and the item
135
+ // agree. Forced, because the item keeps its `queueItemId` and an identity
136
+ // comparison would find the slice already correct.
137
+ //
138
+ // Read before the rebuild, which empties the set: a track the status
139
+ // observer already reported is one the consumer has been told about, and
140
+ // the drop is the same failure arriving by a second route.
141
+ let alreadyReported = self.reportedFailureQueueItemIds.contains(qid)
142
+ // The report is filed after: a forced rebuild clears the set, and the
143
+ // re-seated item failing again is then suppressed by this entry, so one
144
+ // dropped track still reports once.
145
+ self.performingMutation { self.fullRebuildPlayerQueue(forcingReinstall: true) }
146
+ self.reportedFailureQueueItemIds.insert(qid)
147
+ // The queue still parks — index, pause, intent and the re-seat above all
148
+ // stand — it just does not say so twice.
149
+ if alreadyReported { return }
150
+ let err = self.buildPlaybackError(
151
+ nil,
152
+ mapped: .fileFormatNotRecognized,
153
+ queueItemId: qid,
154
+ url: self.tracks[safe: index]?.url ?? "",
155
+ nativeDomainOverride: "PLAYER_ITEM_DROPPED"
156
+ )
157
+ self.errorListeners.forEach { $0(err) }
158
+ self.emitState(.error, reason: .error)
159
+ }
160
+
98
161
  /// Debug-only log of the classifier's verdict for the current item next to
99
162
  /// the URL its asset is bound to and its `sourceURL`.
100
163
  ///
@@ -162,6 +225,13 @@ extension TrackPlayer {
162
225
  self.lastEmittedQueueItemId = currentItemId
163
226
  self.lastErrorQueueItemId = nil
164
227
  self.lastErrorCode = nil
228
+ // A report against some OTHER track says nothing about the one now being
229
+ // announced, so it goes. A report against the track being announced
230
+ // stands: an item that is already failed when it is seated has its error
231
+ // dispatched before this, the first announcement of its position, and
232
+ // clearing here would leave the queue with nothing to hold on.
233
+ self.reportedFailureQueueItemIds = self.reportedFailureQueueItemIds
234
+ .intersection(currentItemId.map { [$0] } ?? [])
165
235
  // A real track (re)loaded => no longer at the end of the queue. At true
166
236
  // end-of-queue this block is skipped entirely — the last-track id still
167
237
  // matches `lastEmittedQueueItemId` — so `reachedQueueEnd` stays set; the
@@ -453,7 +523,11 @@ extension TrackPlayer {
453
523
  // The buffer has recovered and the user still wants playback: nothing else
454
524
  // will restart it. Not while the session is interrupted — the refill can
455
525
  // land mid-call, and the interruption's `.ended` is what resumes then.
456
- if self.isRecoveringFromStall, computed == .full, self.wantsToPlay, !self.isInterrupted {
526
+ // Not while the queue is stopped on a reported failure either: the player
527
+ // has already moved onto the track behind it, and a refill is not the
528
+ // listener asking for that track.
529
+ if self.isRecoveringFromStall, computed == .full, self.wantsToPlay,
530
+ !self.isInterrupted, self.reportedFailureOnCurrentTrack == nil {
457
531
  self.isRecoveringFromStall = false
458
532
  self.beginPlayback()
459
533
  }
@@ -249,7 +249,13 @@ extension TrackPlayer {
249
249
  for: self.config.audioCategoryOptions,
250
250
  contentType: self.config.audioContentType
251
251
  ))
252
- self.beginPlayback()
252
+ // Not when the queue is stopped on a reported failure: the player is
253
+ // parked on a track the consumer was told failed, and `beginPlayback`
254
+ // writes a rate without consulting the transport intent, so resuming
255
+ // here would start it — or the track behind it — unasked.
256
+ if self.reportedFailureOnCurrentTrack == nil {
257
+ self.beginPlayback()
258
+ }
253
259
  case .endedShouldNotResume:
254
260
  // Interruption ended but user resolved it in a way that
255
261
  // shouldn't restart music. The player is already paused
@@ -602,6 +608,7 @@ extension TrackPlayer {
602
608
  // (the dedup is per-item).
603
609
  self.lastErrorQueueItemId = nil
604
610
  self.lastErrorCode = nil
611
+ self.reportedFailureQueueItemIds.removeAll()
605
612
  self.pendingRetryQueueItemId = nil
606
613
  self.retryAttemptsRemaining.removeAll()
607
614
  self.lastReportedState = .none
@@ -859,6 +866,33 @@ extension TrackPlayer {
859
866
  // An echo of a failure whose attempt has not run yet belongs to that
860
867
  // attempt, not to the consumer.
861
868
  if let qidReal = item?.queueItemId, self.pendingRetryQueueItemId == qidReal { return }
869
+ // Already reported and not yet recovered. The `(item, code)` dedup below
870
+ // cannot carry this on its own: a source the player keeps re-attempting —
871
+ // a server answering a ranged request with a whole body makes AVFoundation
872
+ // do exactly that — fails under more than one code, and codes that
873
+ // alternate defeat a one-slot key. Every recovery path clears the field, so
874
+ // the next thing the consumer asks for reports again.
875
+ if let qidReal = item?.queueItemId, self.reportedFailureQueueItemIds.contains(qidReal) { return }
876
+ // Once the queue has stopped on the current track, the tracks behind it go
877
+ // quiet. The `AVPlayerItemFailedToPlayToEndTime` observer is registered
878
+ // against every item, so a window of unplayable sources reports each of
879
+ // them — turning one `play()` into one error per broken track, which is the
880
+ // flicker the listener sees. They have already been told the track they are
881
+ // on failed; the rest is noise they cannot act on.
882
+ //
883
+ // While playback is healthy this does not fire, so a standby leg failing to
884
+ // preroll under crossfade is still surfaced — the leading leg is playing,
885
+ // nothing has been reported against it, and that failure is news.
886
+ //
887
+ // Nothing is lost either way: a suppressed track reports when the player
888
+ // seats it and it fails as the current one, or when the player drops it and
889
+ // `stopOnDroppedTrack` reports it against the position it skipped.
890
+ if let failedQid = item?.queueItemId,
891
+ let currentQid = self.queueItemIds[safe: self.currentTrackIndex],
892
+ failedQid != currentQid,
893
+ self.reportedFailureQueueItemIds.contains(currentQid) {
894
+ return
895
+ }
862
896
  if self.lastErrorQueueItemId == qid && self.lastErrorCode == mapped { return }
863
897
  self.lastErrorQueueItemId = qid
864
898
  self.lastErrorCode = mapped
@@ -896,6 +930,24 @@ extension TrackPlayer {
896
930
  url: failedUrl,
897
931
  nativeDomainOverride: nativeDomainOverride
898
932
  )
933
+ if let failedQid = item?.queueItemId {
934
+ // The consumer now knows this track failed, so the queue holds on it
935
+ // until something asks for playback again.
936
+ self.reportedFailureQueueItemIds.insert(failedQid)
937
+ // `AVQueuePlayer` consumes a failed item, so by now its current item is
938
+ // whatever was installed behind the failure. The gapless status path
939
+ // pauses before it gets here; the crossfade path has no queue player to
940
+ // pause, and neither covers a mid-playback failure.
941
+ self.engine?.pause()
942
+ // A failure that will not recover on its own ends the intent to play, the
943
+ // way reaching the end of the queue does. Left set for a transient
944
+ // failure whose attempts are merely spent, because that is what
945
+ // `handleNetworkRestored` reads through `NetworkRecoveryPolicy` when the
946
+ // network comes back. The resumes that do not consult this flag — a
947
+ // stall recovery, an interruption ending — are held off by the entry
948
+ // above instead, so neither case starts the track behind the failure.
949
+ if !PlaybackErrorMapping.isTransient(mapped) { self.wantsToPlay = false }
950
+ }
899
951
  self.errorListeners.forEach { $0(err) }
900
952
  self.emitState(.error, reason: .error)
901
953
  }
@@ -80,6 +80,7 @@ extension TrackPlayer {
80
80
  // coded error on the new queue's first item.
81
81
  self.lastErrorQueueItemId = nil
82
82
  self.lastErrorCode = nil
83
+ self.reportedFailureQueueItemIds.removeAll()
83
84
  // Reset auto-retry counters: new queueItemIds → new budget
84
85
  // entries. The retry budget is per-queue-position; old
85
86
  // entries from the prior queue are gone with the prior
@@ -509,6 +510,7 @@ extension TrackPlayer {
509
510
  self.lastEmittedQueueItemId = nil
510
511
  self.lastErrorQueueItemId = nil
511
512
  self.lastErrorCode = nil
513
+ self.reportedFailureQueueItemIds.removeAll()
512
514
  self.retryAttemptsRemaining.removeAll()
513
515
  // Fire queue-change BEFORE the synthetic track-change so a
514
516
  // `useQueue()` consumer sees the empty queue + index -1 together,
@@ -682,6 +684,15 @@ extension TrackPlayer {
682
684
  /// terminally. A failed item keeps its `queueItemId`, so identity cannot
683
685
  /// tell a dead item from a live one and the caller has to say.
684
686
  internal func fullRebuildPlayerQueue(forcingReinstall: Bool = false) {
687
+ // A forced reinstall builds the current position from scratch, so whatever
688
+ // was reported against it no longer describes what is installed. Its
689
+ // callers — `play()`, `retry()` and `retryFailedItem` — drop the position
690
+ // they attempt themselves too; this covers the case where the rebuild below
691
+ // bails at its own guards, and a skip that seats without rebuilding clears
692
+ // its target in `applyNewCurrentIndex`. `stopOnDroppedTrack` also forces a
693
+ // reinstall and files its report after this line, so the clear does not
694
+ // reach it.
695
+ if forcingReinstall { self.reportedFailureQueueItemIds.removeAll() }
685
696
  assertQueueIdsInvariant("fullRebuildPlayerQueue")
686
697
  guard let engine = self.engine else { return }
687
698
  guard self.currentTrackIndex >= 0,
@@ -105,6 +105,22 @@ extension TrackPlayer {
105
105
  appLifecycleObservers.removeAll()
106
106
  }
107
107
 
108
+ /// The current position's `queueItemId` when its failure has been reported
109
+ /// and not yet recovered; nil otherwise.
110
+ ///
111
+ /// The position matters because the queue only holds where it is for *this*
112
+ /// track's failure. A report against a track the playhead has since left says
113
+ /// nothing about whether the current one can play.
114
+ ///
115
+ /// Nil when the failing item carried no `queueItemId` — nothing can be
116
+ /// correlated to a position then, so the queue behaves as it did before the
117
+ /// failure. `AVQueueBuilder.makeItem` always sets one; raw items do not.
118
+ internal var reportedFailureOnCurrentTrack: String? {
119
+ guard let currentId = self.queueItemIds[safe: self.currentTrackIndex],
120
+ self.reportedFailureQueueItemIds.contains(currentId) else { return nil }
121
+ return currentId
122
+ }
123
+
108
124
  /// Retry the current item when the network comes back after an outage.
109
125
  ///
110
126
  /// The common real-world case is a tunnel, a lift, or airplane mode: the
@@ -157,6 +173,7 @@ extension TrackPlayer {
157
173
  // The attempt has arrived, so whatever fails next is a new failure rather
158
174
  // than an echo of the one being retried.
159
175
  if self.pendingRetryQueueItemId == qid { self.pendingRetryQueueItemId = nil }
176
+ self.reportedFailureQueueItemIds.remove(qid)
160
177
  guard let engine = self.engine else { return }
161
178
  // Verify the failed item is still the current one. A subsequent
162
179
  // user skip / queue mutation could have moved past it; in that
@@ -274,6 +274,16 @@ extension TrackPlayer {
274
274
  guard newIndex >= 0, newIndex < self.tracks.count else { return }
275
275
 
276
276
  let oldIndex = self.currentTrackIndex
277
+ // Seating a position is an attempt at it, so whatever was reported against
278
+ // it no longer describes what is about to play. This is the clear point for
279
+ // every skip, including the ones that seat without rebuilding — a crossfade
280
+ // skip onto a position the engine still holds, and a forward skip that
281
+ // trims the installed run — which would otherwise leave the report standing
282
+ // and the track silently wedged: no report, no run extension, no end
283
+ // verdict, until play().
284
+ if let seated = self.queueItemIds[safe: newIndex] {
285
+ self.reportedFailureQueueItemIds.remove(seated)
286
+ }
277
287
  if newIndex == oldIndex {
278
288
  // A same-index skip is an explicit "restart this track" — no longer at
279
289
  // the end of the queue. Clear `reachedQueueEnd` here because this branch returns
@@ -7,32 +7,35 @@ extension TrackPlayer {
7
7
  func play() throws -> Promise<Void> {
8
8
  return enqueue {
9
9
  if CastTransportRouter.routePlay() { return }
10
- // Stuck-recovery: a user re-tapping play after an exhausted-retries error
11
- // is asking for this track again, so it gets the allowance the consumer
12
- // configured back and a rebuild. Not a hard-coded 1: `autoRetries: 0`
13
- // asked for none.
14
- if self.lastReportedState == .error,
15
- self.currentTrackIndex >= 0,
16
- self.currentTrackIndex < self.queueItemIds.count {
17
- let qid = self.queueItemIds[self.currentTrackIndex]
18
- if (self.retryAttemptsRemaining[qid] ?? 0) == 0 {
19
- // Asking for this track to play is incompatible with the queue being
20
- // over, so `reachedQueueEnd` cannot survive it.
21
- self.reachedQueueEnd = false
22
- self.retryAttemptsRemaining[qid] = self.effectiveAutoRetries()
23
- // A scheduled attempt for this item is superseded by the rebuild below.
24
- if self.pendingRetryQueueItemId == qid { self.pendingRetryQueueItemId = nil }
25
- self.lastErrorQueueItemId = nil
26
- self.lastErrorCode = nil
27
- // Rebuild whenever an engine is live the rebuild is
28
- // engine-agnostic, and the failed item has to be
29
- // reconstructed on either engine for the retry to reach it.
30
- // Forced: the failed item keeps its `queueItemId`, so a rebuild
31
- // that compares identity would find nothing to do.
32
- if self.engine != nil {
33
- self.performingMutation {
34
- self.fullRebuildPlayerQueue(forcingReinstall: true)
35
- }
10
+ // Stuck-recovery: the queue holds on a track whose failure was reported,
11
+ // and pressing play is asking for that track again so it gets the
12
+ // allowance the consumer configured back and a rebuild. Not a hard-coded
13
+ // 1: `autoRetries: 0` asked for none.
14
+ if let qid = self.reportedFailureOnCurrentTrack {
15
+ // Asking for this track to play is incompatible with the queue being
16
+ // over, so `reachedQueueEnd` cannot survive it.
17
+ self.reachedQueueEnd = false
18
+ self.retryAttemptsRemaining[qid] = self.effectiveAutoRetries()
19
+ // This track only. A standby leg's report is a different track's, and
20
+ // discarding it here would hand the listener that failure a second
21
+ // time when the engine seats it.
22
+ self.reportedFailureQueueItemIds.remove(qid)
23
+ // A scheduled attempt for this item is superseded by the rebuild below.
24
+ // Reachable because the two are set by different errors on the same
25
+ // item: a reported failure leaves the report standing, and a later
26
+ // failure carrying a different code is transient enough to schedule an
27
+ // attempt. Leaving it would fire a second rebuild behind this one.
28
+ if self.pendingRetryQueueItemId == qid { self.pendingRetryQueueItemId = nil }
29
+ self.lastErrorQueueItemId = nil
30
+ self.lastErrorCode = nil
31
+ // Rebuild whenever an engine is live the rebuild is
32
+ // engine-agnostic, and the failed item has to be
33
+ // reconstructed on either engine for the retry to reach it.
34
+ // Forced: the failed item keeps its `queueItemId`, so a rebuild
35
+ // that compares identity would find nothing to do.
36
+ if self.engine != nil {
37
+ self.performingMutation {
38
+ self.fullRebuildPlayerQueue(forcingReinstall: true)
36
39
  }
37
40
  }
38
41
  }
@@ -89,6 +92,8 @@ extension TrackPlayer {
89
92
  if self.pendingRetryQueueItemId == qid { self.pendingRetryQueueItemId = nil }
90
93
  self.lastErrorQueueItemId = nil
91
94
  self.lastErrorCode = nil
95
+ // This track only, for the reason `play()` gives above.
96
+ self.reportedFailureQueueItemIds.remove(qid)
92
97
  // Rebuild whenever an engine is live — the rebuild is
93
98
  // engine-agnostic, and the failed item has to be reconstructed
94
99
  // on either engine for the retry to reach it. Forced: the failed
@@ -17,6 +17,21 @@ extension TrackPlayer {
17
17
  // installing into it here would put the finished track back and let the
18
18
  // rate the engine restores carry it on playing.
19
19
  guard !engine.allMediaItems.isEmpty, !self.reachedQueueEnd else { return }
20
+ // A current position that has failed is not put back, whether its failure
21
+ // has been reported or an attempt for it is still scheduled. `AVQueuePlayer`
22
+ // consumes a failed item, and when the run holds something behind it the
23
+ // player advances there instead of draining — so the end verdict never runs
24
+ // and this reconcile is what rebuilds. It rebuilds from `currentTrackIndex`,
25
+ // still the failed position because the resync needs a play-to-end and a
26
+ // failure fires `AVPlayerItemFailedToPlayToEndTime` instead, and a reinstall
27
+ // is exactly what a missing current position asks for. The rebuilt item
28
+ // fails the same way and its error is swallowed — as a repeat, or as an echo
29
+ // of the pending attempt — so the rebuild would repeat unannounced.
30
+ guard self.reportedFailureOnCurrentTrack == nil else { return }
31
+ if let currentId = self.queueItemIds[safe: self.currentTrackIndex],
32
+ self.pendingRetryQueueItemId == currentId {
33
+ return
34
+ }
20
35
  self.performingMutation { self.fullRebuildPlayerQueue() }
21
36
  }
22
37
 
@@ -92,6 +107,23 @@ extension TrackPlayer {
92
107
  self.pendingRetryQueueItemId == currentId {
93
108
  return
94
109
  }
110
+ // A drain that follows the current item's reported failure is that failure,
111
+ // not the end of the queue. The same `.advance` walks a failed item out of
112
+ // the player, and with nothing installed behind it the queue drains to
113
+ // `currentItem == nil` and lands here. Deciding a verdict would move past a
114
+ // track the consumer has just been told failed — and when the tracks behind
115
+ // it fail the same way, the advance, rebuild and resume repeat with nothing
116
+ // counting them, wrapping forever under repeat-all. The queue stays where it
117
+ // is, in `.error`, until `play()` or `retry()` asks for the track again.
118
+ if self.reportedFailureOnCurrentTrack != nil {
119
+ // Park the engine, as the queue-end branch below does and for the same
120
+ // reason: neither engine drops its rate when it runs out of items, so a
121
+ // leg left running would start whatever lands on it next. The gapless
122
+ // failure path pauses the `AVQueuePlayer` itself, but the crossfade one
123
+ // has no `AVQueuePlayer` to pause.
124
+ self.engine?.pause()
125
+ return
126
+ }
95
127
  var verdict = QueueWindowArithmetic.verdict(
96
128
  drained: true,
97
129
  atLogicalTail: QueueWindowArithmetic.isAtLogicalTail(
@@ -405,6 +405,32 @@ class TrackPlayer: HybridTrackPlayerSpec {
405
405
  internal var lastErrorQueueItemId: String?
406
406
  internal var lastErrorCode: PlaybackErrorCode?
407
407
 
408
+ /// `queueItemId` of every track whose playback error has been reported to
409
+ /// the consumer and not yet recovered.
410
+ ///
411
+ /// A set, not one slot: several tracks of an installed run can be failing at
412
+ /// once — a window of unplayable sources does exactly that — and a single
413
+ /// slot cannot hold them. Each fire replaces the last, so a later fire for a
414
+ /// track already reported passes the check and reports again — one broken
415
+ /// queue then hands the listener an unbounded stream of `onError`.
416
+ ///
417
+ /// Written where `dispatchErrorOrRetry` hands an `onError` out and where
418
+ /// `stopOnDroppedTrack` reports a track the player discarded, so it names a
419
+ /// track the consumer has been told about and nothing has recovered. The
420
+ /// queue neither advances past that track nor puts it back until the track's
421
+ /// entry is cleared, which `play()`, `retry()`, `retryFailedItem`, a track
422
+ /// change to a different track, a skip that seats the position, `setQueue`,
423
+ /// `clearQueue`, `destroy` and a forced `fullRebuildPlayerQueue` all do.
424
+ /// Deliberately NOT a state emit of `.playing`: a source the player keeps
425
+ /// re-attempting flips to playing between attempts, so clearing there would
426
+ /// reopen the report for a track already reported.
427
+ ///
428
+ /// Separate from `lastErrorQueueItemId` even though both name a failing
429
+ /// track: that one exists to dedup repeat reports and is cleared whenever a
430
+ /// repeat would be wanted again, which is not the same question as whether
431
+ /// the queue is still stopped.
432
+ internal var reportedFailureQueueItemIds: Set<String> = []
433
+
408
434
  /// `queueItemId` of the item whose automatic attempt is scheduled but has
409
435
  /// not run yet.
410
436
  ///