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
@@ -840,6 +840,38 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
840
840
  * initiation so the retry-then-fail can fire a fresh error. */
841
841
  private var lastErrorKey: Pair<Int, PlaybackErrorCode>? = null
842
842
 
843
+ /**
844
+ * Positions whose playback error has been reported to the consumer and not
845
+ * recovered since. Empty when nothing is outstanding, and `-1` is never a
846
+ * member — a failure carrying no track records nothing, which is what keeps
847
+ * `currentTrackIndex in this` from reading as stopped while no track is
848
+ * seated.
849
+ *
850
+ * A set, not one position: under crossfade the standby leg prerolls the next
851
+ * track, so a broken one is reported while the leading track still plays and
852
+ * both are outstanding at once. Holding only the latest would hand the
853
+ * listener the standby's failure a second time when the engine seats it.
854
+ * The queue is stopped when `currentTrackIndex` is a member.
855
+ *
856
+ * Written only where [handlePlayerError] hands an `onError` out, so it names
857
+ * a track the consumer has been told about and nothing has recovered.
858
+ * [retryFailedItem] and every skip drop the entry for the position they
859
+ * attempt; a track change keeps only the announced position's; `setQueue`,
860
+ * `clearQueue`, `destroy` and `shuffleQueue` empty it, and `addToQueue`,
861
+ * `removeFromQueue` and `moveInQueue` keep only the current track's, carried
862
+ * to its new index. `onCrossfadeCancel` deliberately keeps the set: it
863
+ * returns to the track already playing rather than announcing a new one, and
864
+ * the prerolled track is still queued and still broken. Deliberately NOT a
865
+ * PLAYING emit: a source the player keeps re-attempting flips to playing
866
+ * between attempts, so clearing there would reopen a track's report.
867
+ *
868
+ * Separate from [lastErrorKey] even though both name a failing track: that
869
+ * one exists to dedup repeat reports and is cleared whenever a repeat would
870
+ * be wanted again, which is not the same question as whether the queue is
871
+ * still stopped. Mirrors the iOS `reportedFailureQueueItemIds` set.
872
+ */
873
+ private val reportedFailureIndices = mutableSetOf<Int>()
874
+
843
875
  /** Auto-retry counter per queue position, keyed on
844
876
  * `currentTrackIndex` (lib-internal authoritative position; never
845
877
  * derived from consumer fields). Decremented on each
@@ -1369,6 +1401,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1369
1401
  pendingStateChangeReason = null
1370
1402
  pendingTrackChangeReason = null
1371
1403
  lastErrorKey = null
1404
+ reportedFailureIndices.clear()
1372
1405
  cancelPendingRetry()
1373
1406
  retryAttemptsRemaining.clear()
1374
1407
  if (current === this) current = null
@@ -1675,6 +1708,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1675
1708
  // last item must not suppress an identical coded error on the
1676
1709
  // new queue's first item.
1677
1710
  lastErrorKey = null
1711
+ reportedFailureIndices.clear()
1678
1712
  cancelPendingRetry()
1679
1713
  // Reset auto-retry counters: new queue → new budget per index.
1680
1714
  val attempts = effectiveAutoRetries()
@@ -1783,6 +1817,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1783
1817
  val ctx = boundContext ?: return -1
1784
1818
 
1785
1819
  val at = QueueMutationArithmetic.clampInsertBefore(insertBefore, tracks.size)
1820
+ // A mutation renumbers positions, so every recorded index but the current
1821
+ // track's is stale afterwards. The one that matters is the stop on the
1822
+ // track being heard: it follows the index through the mutation, and the
1823
+ // rest go rather than being carried onto whatever now sits at their number.
1824
+ val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
1786
1825
  this.tracks = tracks.toMutableList().apply { addAll(at, newTracks) }
1787
1826
  this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
1788
1827
  currentIndex = currentTrackIndex,
@@ -1809,6 +1848,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1809
1848
  for (newIdx in at until at + shift) {
1810
1849
  retryAttemptsRemaining[newIdx] = attempts
1811
1850
  }
1851
+ reportedFailureIndices.clear()
1852
+ if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
1812
1853
  val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
1813
1854
  serviceBinder?.engine?.addMediaItems(items, insertBefore = at)
1814
1855
  // Diverges from iOS: add-into-empty-queue on iOS leaves the
@@ -1879,6 +1920,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1879
1920
  newTracks.removeAt(i)
1880
1921
  }
1881
1922
  this.tracks = newTracks
1923
+ // A mutation renumbers positions, so every recorded index but the current
1924
+ // track's is stale afterwards. The one that matters is the stop on the
1925
+ // track being heard: it follows the index through the mutation, and the
1926
+ // rest go rather than being carried onto whatever now sits at their number.
1927
+ val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
1882
1928
  this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterRemove(
1883
1929
  currentIndex = oldCur,
1884
1930
  sanitisedIndices = sanitised,
@@ -1921,6 +1967,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1921
1967
  val attempts = effectiveAutoRetries()
1922
1968
  retryAttemptsRemaining.clear()
1923
1969
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
1970
+ reportedFailureIndices.clear()
1971
+ if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
1924
1972
  // Mirror the wake-mode hook on `setQueueInternal` /
1925
1973
  // `addToQueueInternal`: removing the last remote URL out of a
1926
1974
  // mixed queue must downgrade to LOCAL so the WiFi wake lock
@@ -1978,6 +2026,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1978
2026
  mutable.add(to, moved)
1979
2027
  this.tracks = mutable
1980
2028
 
2029
+ // A mutation renumbers positions, so every recorded index but the current
2030
+ // track's is stale afterwards. The one that matters is the stop on the
2031
+ // track being heard: it follows the index through the mutation, and the
2032
+ // rest go rather than being carried onto whatever now sits at their number.
2033
+ val wasStoppedOnCurrent = currentTrackIndex in reportedFailureIndices
1981
2034
  this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterMove(
1982
2035
  currentIndex = currentTrackIndex,
1983
2036
  fromIndex = from,
@@ -1995,6 +2048,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1995
2048
  val attempts = effectiveAutoRetries()
1996
2049
  retryAttemptsRemaining.clear()
1997
2050
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
2051
+ reportedFailureIndices.clear()
2052
+ if (wasStoppedOnCurrent) reportedFailureIndices.add(currentTrackIndex)
1998
2053
  rescheduleLookahead()
1999
2054
  recomputeCapabilities()
2000
2055
  // Reached only past the invalid / `from == to` / pinned-current
@@ -2033,6 +2088,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2033
2088
  // Reset error-dedup so a subsequent queue's identical-coded error
2034
2089
  // isn't suppressed by a stale entry from the cleared queue.
2035
2090
  lastErrorKey = null
2091
+ reportedFailureIndices.clear()
2036
2092
  cancelPendingRetry()
2037
2093
  retryAttemptsRemaining.clear()
2038
2094
  tracks = emptyList()
@@ -2092,12 +2148,30 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2092
2148
  // error, hand the track back the allowance the consumer configured and
2093
2149
  // re-prepare. Not a hard-coded 1: a consumer who set `autoRetries: 0`
2094
2150
  // asked for none. Mirrors iOS `play()` stuck-recovery.
2095
- if (lastReportedState == PlayerState.ERROR &&
2096
- currentTrackIndex >= 0 &&
2097
- (retryAttemptsRemaining[currentTrackIndex] ?: 0) == 0) {
2151
+ if (currentTrackIndex >= 0 && currentTrackIndex in reportedFailureIndices) {
2098
2152
  retryAttemptsRemaining[currentTrackIndex] = effectiveAutoRetries()
2099
2153
  lastErrorKey = null
2100
- player?.prepare()
2154
+ // `retryFailedItem`, not a bare `prepare()`: a bare prepare re-uses
2155
+ // the wedged DataSource this exists to discard. It removes and re-adds
2156
+ // the item, which rebuilds the source from the MediaSource.Factory.
2157
+ retryFailedItem(currentTrackIndex)
2158
+ } else if (currentTrackIndex >= 0 &&
2159
+ player?.playbackState == Player.STATE_IDLE &&
2160
+ (player?.mediaItemCount ?: 0) > 0
2161
+ ) {
2162
+ // With no current track there is no position to re-seat, which the
2163
+ // index guard above covers; the branch below refuses to start
2164
+ // anything in that state at all.
2165
+ // A failure leaves ExoPlayer in STATE_IDLE, and a skip off the failed
2166
+ // track seeks without re-preparing — so `play()` would set
2167
+ // `playWhenReady` on a player that never leaves IDLE and nothing would
2168
+ // be heard. `retryFailedItem`, not a bare `prepare()`: on a same-index
2169
+ // skip the item is the one that failed, and a bare prepare re-uses the
2170
+ // DataSource that wedged. The timeline has to hold items for prepare to
2171
+ // reach anything — between `clearQueue` and the next `setMediaItems`
2172
+ // it is empty, and preparing there lands in STATE_ENDED, which the
2173
+ // line below reads as a finished queue.
2174
+ retryFailedItem(currentTrackIndex)
2101
2175
  }
2102
2176
  // At the end of the queue there is nothing to play: ExoPlayer stays
2103
2177
  // STATE_ENDED through play(), and a playWhenReady left set would
@@ -2105,6 +2179,12 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2105
2179
  // seek or a fresh queue leaves this state; play() alone restarts
2106
2180
  // nothing — the same contract as iOS.
2107
2181
  if (player?.playbackState == Player.STATE_ENDED && tracks.isNotEmpty()) return@onMain
2182
+ // Nothing is seated: an add into an empty queue primes the player and
2183
+ // deliberately leaves the current index at -1, so starting here would
2184
+ // play position 0 while every read still answers for no track. The
2185
+ // consumer seats one — `skipToIndex(0)` — and plays. iOS reaches the
2186
+ // same outcome by leaving its queue player empty until then.
2187
+ if (currentTrackIndex < 0) return@onMain
2108
2188
  // Stash USER on the pending-reason channel so the
2109
2189
  // subsequent Player.Listener fire attributes the transition
2110
2190
  // correctly.
@@ -2140,6 +2220,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2140
2220
  lastErrorKey = null
2141
2221
  pendingStateChangeReason = StateChangeReason.USER
2142
2222
  wantsToPlay = true
2223
+ // Drops this track's report on its way through, before its own
2224
+ // current-track bail, so a retry always clears what it attempts.
2143
2225
  retryFailedItem(currentTrackIndex)
2144
2226
  recomputeBufferState()
2145
2227
  }
@@ -2374,6 +2456,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2374
2456
  val p = player ?: return
2375
2457
  if (target < 0 || target >= tracks.size) return
2376
2458
  currentTrackIndex = target
2459
+ // Seating a position is an attempt at it, so the report against it no
2460
+ // longer describes what is about to play. Cleared here rather than left to
2461
+ // the media-item transition, which does not fire for a skip that seats the
2462
+ // track already selected. Mirrors iOS `applyNewCurrentIndex`.
2463
+ reportedFailureIndices.remove(target)
2377
2464
  pendingTrackChangeReason = TrackChangeReason.USER_SKIP_TO_INDEX
2378
2465
  // Guard against IllegalSeekPositionException: `seekTo(index, 0L)`
2379
2466
  // throws when `index` is outside `mediaItemCount`. When the lib
@@ -2401,6 +2488,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2401
2488
  val p = player ?: return
2402
2489
  val next = computeNextIndex() ?: return
2403
2490
  currentTrackIndex = next
2491
+ reportedFailureIndices.remove(next)
2404
2492
  pendingTrackChangeReason = TrackChangeReason.USER_SKIP_NEXT
2405
2493
  if (next < p.mediaItemCount) {
2406
2494
  serviceBinder?.engine?.seekTo(next, 0L)
@@ -2439,6 +2527,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2439
2527
  is QueueSkipArithmetic.SkipToPreviousAction.Previous -> {
2440
2528
  val prev = action.index
2441
2529
  currentTrackIndex = prev
2530
+ reportedFailureIndices.remove(prev)
2442
2531
  pendingTrackChangeReason = TrackChangeReason.USER_SKIP_PREVIOUS
2443
2532
  if (prev < p.mediaItemCount) {
2444
2533
  serviceBinder?.engine?.seekTo(prev, 0L)
@@ -2453,6 +2542,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2453
2542
  // untouched (stashing a reason no transition consumes would leak
2454
2543
  // into the next real skip).
2455
2544
  if (currentTrackIndex in 0 until p.mediaItemCount) {
2545
+ reportedFailureIndices.remove(currentTrackIndex)
2456
2546
  serviceBinder?.engine?.seekTo(currentTrackIndex, 0L)
2457
2547
  }
2458
2548
  }
@@ -3084,6 +3174,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3084
3174
  val attempts = effectiveAutoRetries()
3085
3175
  retryAttemptsRemaining.clear()
3086
3176
  for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
3177
+ // The permutation decides what lands at position 0, so the track the queue
3178
+ // stopped on is not the one the index now names. The stop is dropped with
3179
+ // the retry budget rather than carried onto whichever track was moved to
3180
+ // the front.
3181
+ reportedFailureIndices.clear()
3087
3182
 
3088
3183
  val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
3089
3184
  serviceBinder?.engine?.setMediaItems(items, startIndex = 0, startPositionMs = 0L)
@@ -3961,6 +4056,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3961
4056
  if (incomingIndex !in tracks.indices) return
3962
4057
  currentTrackIndex = incomingIndex
3963
4058
  lastErrorKey = null
4059
+ reportedFailureIndices.retainAll(setOf(incomingIndex))
3964
4060
  val track = tracks[incomingIndex]
3965
4061
  currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
3966
4062
  crossfadeFadeStartEmittedIdx = incomingIndex
@@ -4008,6 +4104,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4008
4104
  if (revertedIndex !in tracks.indices) return
4009
4105
  currentTrackIndex = revertedIndex
4010
4106
  lastErrorKey = null
4107
+ // The reported set is left alone. A cancel goes back to the track that was
4108
+ // already playing rather than announcing a new one, and the track the
4109
+ // standby leg was prerolling is still in the queue and still broken — the
4110
+ // engine seats it next. Dropping its report here would hand the listener
4111
+ // the same failure a second time when it does.
4011
4112
  crossfadeFadeStartEmittedIdx = null
4012
4113
  val track = tracks[revertedIndex]
4013
4114
  currentTrackSource = classifyTrackSource(track.url, ::servedFromCache)
@@ -4189,6 +4290,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4189
4290
  // suppressed even though they're independent failures. Mirrors
4190
4291
  // iOS reset in `handleCurrentItemDidChange`.
4191
4292
  lastErrorKey = null
4293
+ // A report against some other track says nothing about the one now
4294
+ // being announced, so it goes. A report against the track being
4295
+ // announced stands — clearing it would leave nothing to hold on.
4296
+ reportedFailureIndices.retainAll(setOf(currentTrackIndex))
4192
4297
  val track = if (currentTrackIndex in tracks.indices) tracks[currentTrackIndex] else null
4193
4298
  currentTrackSource = classifyTrackSource(track?.url, ::servedFromCache)
4194
4299
 
@@ -4389,6 +4494,26 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4389
4494
  // An echo of a failure whose attempt has not run yet belongs to that
4390
4495
  // attempt, not to the consumer.
4391
4496
  if (idx >= 0 && idx == pendingRetryIndex) return
4497
+ // Already reported and not yet recovered. Under crossfade the standby leg
4498
+ // prerolls the next track, so a broken one is reported while the leading
4499
+ // track still plays; the engine then seats it and it fails again as the
4500
+ // current track. `lastErrorKey` cannot carry that on its own — the track
4501
+ // change between the two clears it — so one thing asked for would report
4502
+ // twice. The state still has to be announced when this becomes the track
4503
+ // the listener is on, because the report was raised before it was.
4504
+ if (idx >= 0 && idx in reportedFailureIndices) {
4505
+ if (idx == currentTrackIndex) emitState(PlayerState.ERROR, StateChangeReason.ERROR)
4506
+ return
4507
+ }
4508
+ // Once the queue has stopped on the current track, the tracks behind it go
4509
+ // quiet: the listener has been told the track they are on failed, and one
4510
+ // error per unplayable track in the run is noise they cannot act on. While
4511
+ // playback is healthy this does not fire, so a standby leg failing to
4512
+ // preroll under crossfade is still surfaced. Mirrors iOS.
4513
+ if (idx >= 0 && idx != currentTrackIndex &&
4514
+ currentTrackIndex in reportedFailureIndices) {
4515
+ return
4516
+ }
4392
4517
  if (lastErrorKey == key) return
4393
4518
  lastErrorKey = key
4394
4519
  // Auto-retry path: transient errors with retries left
@@ -4398,9 +4523,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4398
4523
  // Only the track being played is retried. A standby leg's preroll failing
4399
4524
  // is the *next* track failing to load early, and the cancel that precedes
4400
4525
  // the report reverts `currentTrackIndex` to the leading track, so an
4401
- // attempt scheduled for it could never run. Surfacing it instead is both
4402
- // honest and harmless: when that track becomes current it is loaded fresh
4403
- // and gets its own automatic attempt then.
4526
+ // attempt scheduled for it could never run. It is surfaced instead, and
4527
+ // gets its own automatic attempt when it becomes current and loads fresh.
4404
4528
  if (idx >= 0 &&
4405
4529
  idx == currentTrackIndex &&
4406
4530
  PlaybackErrorMapping.isTransient(mapped) &&
@@ -4428,6 +4552,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4428
4552
  val err = PlaybackErrorMapping.buildPlaybackError(
4429
4553
  error, mapped, queueItemId = "", url = failedUrl,
4430
4554
  )
4555
+ // The consumer now knows this track failed, so the queue holds on it until
4556
+ // something asks for playback again. Recorded for whichever track failed,
4557
+ // current or not: that is what keeps the standby leg's report from being
4558
+ // handed to the listener a second time when the engine seats it.
4559
+ if (idx >= 0) reportedFailureIndices.add(idx)
4431
4560
  errorListeners.forEach { it(err) }
4432
4561
  // The state is the current track's. A standby leg's preroll failing is
4433
4562
  // reported above and leaves the leading leg playing; announcing ERROR for
@@ -4477,6 +4606,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4477
4606
  pendingRetryIndex = -1
4478
4607
  pendingRetryRunnable = null
4479
4608
  }
4609
+ // The attempt re-seats the item, so the queue is no longer stopped on it.
4610
+ reportedFailureIndices.remove(atIndex)
4480
4611
  // The failed item must still be the current one; a skip or a queue mutation
4481
4612
  // since the failure makes the retry moot. Checked before the fade is
4482
4613
  // settled, because settling is an audible cut and a retry that is about to
@@ -4492,7 +4623,17 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
4492
4623
  val p = player ?: return
4493
4624
  val current = p.currentMediaItem
4494
4625
  if (current != null) {
4495
- val idx = p.currentMediaItemIndex
4626
+ // Act on the index that was approved above, not on whatever the engine
4627
+ // happens to be pointing at. They agree in every path found so far — the
4628
+ // standby leg carries the whole queue so its index is in the queue's own
4629
+ // space, and the settle above puts the leading leg on the current track —
4630
+ // but nothing enforces it, and a disagreement means the engine has not
4631
+ // caught up with a mutation. Rebuilding at either index would then be
4632
+ // wrong: the track being heard gets torn down and restarted at another
4633
+ // track's position, because `resumeMs` below is that track's. Refuse
4634
+ // instead; whatever asks next attempts it again.
4635
+ if (atIndex >= p.mediaItemCount || p.currentMediaItemIndex != atIndex) return
4636
+ val idx = atIndex
4496
4637
  val resumeMs = p.currentPosition.coerceAtLeast(0L)
4497
4638
  // Force a FRESH MediaSource/DataSource for the failed item without
4498
4639
  // collapsing the rest of the timeline. `replaceMediaItem(idx, current)`
@@ -1,11 +1,16 @@
1
1
  package com.margelo.nitro.queueplayer
2
2
 
3
+ import android.net.Uri
3
4
  import androidx.media3.common.util.UnstableApi
5
+ import androidx.media3.datasource.DataSource
6
+ import androidx.media3.datasource.DataSpec
4
7
  import androidx.media3.datasource.DefaultHttpDataSource
8
+ import androidx.media3.datasource.TransferListener
5
9
  import androidx.media3.datasource.cache.CacheDataSource
6
10
  import androidx.test.core.app.ApplicationProvider
7
11
  import org.junit.After
8
12
  import org.junit.Assert.assertEquals
13
+ import org.junit.Assert.assertFalse
9
14
  import org.junit.Assert.assertNotNull
10
15
  import org.junit.Assert.assertTrue
11
16
  import org.junit.Before
@@ -79,6 +84,61 @@ class LookaheadCacheTest {
79
84
  )
80
85
  }
81
86
 
87
+ /**
88
+ * Only the prefetcher fills the cache. The playback factory is built with a
89
+ * null cache-write sink, which is what Media3 reads as `cacheIsReadOnly`, so
90
+ * a body pulled all the way through it leaves nothing on disk.
91
+ */
92
+ @Test
93
+ fun `a body read through the playback factory is not written to the cache`() {
94
+ val body = ByteArray(4096) { it.toByte() }
95
+ val url = "https://example.com/playback-read.mp3"
96
+ val source = cache.wrapDataSourceFactory(FixedBodyDataSource.Factory(body))
97
+ .createDataSource()
98
+
99
+ source.open(DataSpec.Builder().setUri(url).setKey(url).build())
100
+ val buffer = ByteArray(1024)
101
+ var total = 0
102
+ while (true) {
103
+ val read = source.read(buffer, 0, buffer.size)
104
+ if (read == -1) break
105
+ total += read
106
+ }
107
+ source.close()
108
+
109
+ assertEquals("the whole body reaches the caller", body.size, total)
110
+ assertEquals("nothing is written to disk", 0L, cache.currentSizeBytes())
111
+ assertFalse("and the url is not held as cached", cache.isFullyCached(url))
112
+ }
113
+
114
+ /** Serves a fixed body for any [DataSpec], honouring its position. */
115
+ private class FixedBodyDataSource(private val body: ByteArray) : DataSource {
116
+ class Factory(private val body: ByteArray) : DataSource.Factory {
117
+ override fun createDataSource(): DataSource = FixedBodyDataSource(body)
118
+ }
119
+
120
+ private var uri: Uri? = null
121
+ private var position = 0
122
+
123
+ override fun open(dataSpec: DataSpec): Long {
124
+ uri = dataSpec.uri
125
+ position = dataSpec.position.toInt()
126
+ return (body.size - position).toLong()
127
+ }
128
+
129
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
130
+ if (position >= body.size) return -1
131
+ val take = minOf(length, body.size - position)
132
+ body.copyInto(buffer, offset, position, position + take)
133
+ position += take
134
+ return take
135
+ }
136
+
137
+ override fun getUri(): Uri? = uri
138
+ override fun close() {}
139
+ override fun addTransferListener(transferListener: TransferListener) {}
140
+ }
141
+
82
142
  /**
83
143
  * Disabled means every read goes to the origin and nothing is written,
84
144
  * whatever the disk holds — the wrapper hands out the bare upstream source.
@@ -0,0 +1,163 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ /**
8
+ * What the cache will and will not believe is a track, judged from a response's
9
+ * declared type and the first bytes of its body. Mirrors iOS's
10
+ * `MediaResponseCheckTests` case for case so the two platforms refuse and
11
+ * accept the same responses.
12
+ */
13
+ class MediaResponseCheckTest {
14
+
15
+ private val xmlEnvelope =
16
+ """<api-response status="failed"><error code="70"/></api-response>""".toByteArray()
17
+ private val mp3 = byteArrayOf(0x49, 0x44, 0x33, 0x04, 0, 0, 0, 0)
18
+
19
+ private fun rejection(
20
+ contentType: String?,
21
+ body: ByteArray,
22
+ whole: Boolean = true
23
+ ): MediaResponseCheck.Rejection? =
24
+ MediaResponseCheck.rejection(contentType, body, headIsStartOfResource = whole)
25
+
26
+ @Test
27
+ fun markupAndJsonContentTypesAreRefused() {
28
+ for (type in listOf(
29
+ "application/xml", "text/xml", "text/html",
30
+ "application/xhtml+xml", "application/json", "text/json",
31
+ )) {
32
+ assertEquals(
33
+ "$type is a document type",
34
+ MediaResponseCheck.Rejection.MarkupContentType(type),
35
+ rejection(type, xmlEnvelope)
36
+ )
37
+ }
38
+ }
39
+
40
+ @Test
41
+ fun theDeclaredTypeIsReadWithoutItsParameters() {
42
+ assertEquals(
43
+ MediaResponseCheck.Rejection.MarkupContentType("application/xml"),
44
+ rejection("application/xml; charset=utf-8", xmlEnvelope)
45
+ )
46
+ assertEquals(
47
+ MediaResponseCheck.Rejection.MarkupContentType("application/xml"),
48
+ rejection("APPLICATION/XML", xmlEnvelope)
49
+ )
50
+ }
51
+
52
+ @Test
53
+ fun aDeclaredDocumentIsRefusedOnAPartialBodyToo() {
54
+ assertEquals(
55
+ MediaResponseCheck.Rejection.MarkupContentType("application/xml"),
56
+ rejection("application/xml", byteArrayOf(0x3C, 0x00), whole = false)
57
+ )
58
+ }
59
+
60
+ @Test
61
+ fun aPartialBodyIsNotJudgedByItsLeadingBytes() {
62
+ // The same markup, under a type that is not itself a document, so only the
63
+ // leading-byte rule can decide: refused as a whole resource, accepted as a
64
+ // range, which is what separates the two inputs.
65
+ val markup = "<api-response/>".toByteArray()
66
+ assertEquals(
67
+ MediaResponseCheck.Rejection.MarkupBody,
68
+ rejection("application/octet-stream", markup)
69
+ )
70
+ assertNull(
71
+ "a body that begins part way into a file says nothing about its first bytes",
72
+ rejection("application/octet-stream", markup, whole = false)
73
+ )
74
+ }
75
+
76
+ @Test
77
+ fun textWithoutAContainerSignatureIsRefused() {
78
+ assertEquals(
79
+ MediaResponseCheck.Rejection.TextWithoutMediaSignature("text/plain"),
80
+ rejection("text/plain", "not found".toByteArray())
81
+ )
82
+ }
83
+
84
+ @Test
85
+ fun textCarryingAContainerSignatureIsAccepted() {
86
+ assertNull(
87
+ "a server mislabelling audio as text does not make it a document",
88
+ rejection("text/plain", mp3)
89
+ )
90
+ }
91
+
92
+ @Test
93
+ fun aPlaylistDeclaredAsTextIsAccepted() {
94
+ assertNull(rejection("text/plain", "#EXTM3U\n#EXT-X-VERSION:3".toByteArray()))
95
+ }
96
+
97
+ @Test
98
+ fun aMarkupBodyIsRefusedWhenNothingUsableIsDeclared() {
99
+ assertEquals(MediaResponseCheck.Rejection.MarkupBody, rejection(null, xmlEnvelope))
100
+ assertEquals(
101
+ MediaResponseCheck.Rejection.MarkupBody,
102
+ rejection("application/octet-stream", xmlEnvelope)
103
+ )
104
+ assertEquals(
105
+ MediaResponseCheck.Rejection.MarkupBody,
106
+ rejection(null, "<!DOCTYPE html><html>".toByteArray())
107
+ )
108
+ assertEquals(
109
+ MediaResponseCheck.Rejection.MarkupBody,
110
+ rejection(null, """{"error":"not found"}""".toByteArray())
111
+ )
112
+ }
113
+
114
+ @Test
115
+ fun leadingSpaceAndAByteOrderMarkDoNotHideAMarkupBody() {
116
+ assertEquals(
117
+ MediaResponseCheck.Rejection.MarkupBody,
118
+ rejection(null, "\n <?xml version=\"1.0\"?>".toByteArray())
119
+ )
120
+ assertEquals(
121
+ MediaResponseCheck.Rejection.MarkupBody,
122
+ rejection(null, byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + "<error/>".toByteArray())
123
+ )
124
+ // `FF FE` also satisfies the MPEG frame sync, so a UTF-16 document would
125
+ // read as audio if the mark were not dropped first.
126
+ assertEquals(
127
+ MediaResponseCheck.Rejection.MarkupBody,
128
+ rejection(null, byteArrayOf(0xFF.toByte(), 0xFE.toByte()) + "<e/>".toByteArray())
129
+ )
130
+ }
131
+
132
+ @Test
133
+ fun recognisedContainersAreAccepted() {
134
+ val bodies = mapOf(
135
+ "ID3" to byteArrayOf(0x49, 0x44, 0x33, 0x04, 0, 0, 0, 0),
136
+ "MPEG frame sync" to byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0, 0, 0, 0, 0),
137
+ "ADTS AAC" to byteArrayOf(0xFF.toByte(), 0xF1.toByte(), 0x50, 0x80.toByte(), 0, 0, 0, 0),
138
+ "FLAC" to byteArrayOf(0x66, 0x4C, 0x61, 0x43, 0, 0, 0, 0),
139
+ "Ogg" to byteArrayOf(0x4F, 0x67, 0x67, 0x53, 0, 0, 0, 0),
140
+ "WAV" to byteArrayOf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0),
141
+ "AIFF" to byteArrayOf(0x46, 0x4F, 0x52, 0x4D, 0, 0, 0, 0),
142
+ "CAF" to byteArrayOf(0x63, 0x61, 0x66, 0x66, 0, 0, 0, 0),
143
+ "MP4" to byteArrayOf(0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70),
144
+ )
145
+ for ((name, bytes) in bodies) {
146
+ assertNull("$name is media", rejection("text/plain", bytes))
147
+ assertNull("$name is media", rejection(null, bytes))
148
+ }
149
+ }
150
+
151
+ @Test
152
+ fun anUndeclaredOrOpaqueBinaryBodyIsAccepted() {
153
+ val binary = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04)
154
+ assertNull("an unrecognised container is not a document", rejection(null, binary))
155
+ assertNull(rejection("application/octet-stream", binary))
156
+ assertNull(rejection("audio/mpeg", binary))
157
+ }
158
+
159
+ @Test
160
+ fun anEmptyBodyIsNotJudged() {
161
+ assertNull(rejection(null, ByteArray(0)))
162
+ }
163
+ }