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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +9 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +10 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +149 -8
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +60 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +34 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +86 -0
- package/ios/CrossfadeEngine.swift +12 -0
- package/ios/LookaheadCache.swift +27 -0
- package/ios/MediaResponseCheck.swift +122 -0
- package/ios/Tests/MediaResponseCheckTests.swift +128 -0
- package/ios/Tests/RetryRecoveryTests.swift +115 -0
- package/ios/Tests/TopUpWindowGateTests.swift +47 -0
- package/ios/Tests/TrackPlayerEndVerdictTests.swift +41 -0
- package/ios/TrackPlayer+EventsDispatch.swift +77 -3
- package/ios/TrackPlayer+Lifecycle.swift +53 -1
- package/ios/TrackPlayer+Queue.swift +11 -0
- package/ios/TrackPlayer+Recovery.swift +17 -0
- package/ios/TrackPlayer+Skip.swift +10 -0
- package/ios/TrackPlayer+Transport.swift +31 -26
- package/ios/TrackPlayer+Window.swift +32 -0
- package/ios/TrackPlayer.swift +26 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
11
|
-
// is asking for
|
|
12
|
-
// configured back and a rebuild. Not a hard-coded
|
|
13
|
-
// asked for none.
|
|
14
|
-
if self.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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
|
///
|