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
|
@@ -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 (
|
|
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
|
-
|
|
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.
|
|
4402
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|