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
@@ -115,7 +115,15 @@ class LookaheadCache(
115
115
  * surfacing a load error.
116
116
  */
117
117
  fun wrapDataSourceFactory(upstream: DataSource.Factory): DataSource.Factory {
118
- val cached = cacheDataSourceFactory(upstream)
118
+ // Read-only for playback. A write here would commit whatever the origin
119
+ // answered with — a server's error envelope included — as a track, and the
120
+ // body check that refuses one belongs on the prefetch path, where a false
121
+ // refusal costs a prefetch instead of the track. `null` sets Media3's
122
+ // `cacheIsReadOnly`, which is tested before the default-sink fallback.
123
+ // So playing a track does not cache it and does not complete a partial
124
+ // prefetch of it; only the prefetcher writes. Matches iOS, whose
125
+ // read-through server also only reads.
126
+ val cached = cacheDataSourceFactory(upstream).setCacheWriteDataSinkFactory(null)
119
127
  // Decided per data source, which Media3 creates per item load, so a
120
128
  // change to `servesPlayback` reaches the next item without rebuilding
121
129
  // the player; the item already loading keeps the source it opened.
@@ -516,7 +516,7 @@ class LookaheadCacheWriter @VisibleForTesting internal constructor(
516
516
  // window where we'd want to learn the format. Mirrors iOS's
517
517
  // file-extension persistence on the cached file.
518
518
  val mimeCapturingFactory = MimeCapturingDataSourceFactory(
519
- upstream = httpFactory,
519
+ upstream = MediaValidatingDataSourceFactory(httpFactory),
520
520
  onMime = { uri, contentType -> cache.recordMimeType(uri, contentType) }
521
521
  )
522
522
  val cacheDataSourceFactory = cache.cacheDataSourceFactory(mimeCapturingFactory)
@@ -0,0 +1,156 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ /**
4
+ * Decides whether an HTTP response that claims to carry a track can be
5
+ * believed, from its `Content-Type` and the first bytes of its body.
6
+ *
7
+ * A server that answers an API-level failure with `200` and an XML or JSON
8
+ * envelope is the case this exists for: media servers commonly report
9
+ * errors that way by design, and a proxy answering with an HTML page does the
10
+ * same. Such a body passes every length and status check, so without this the
11
+ * prefetcher writes it into the cache as a complete track and it is served from
12
+ * disk on every later play — the track stays broken after the server is well
13
+ * again.
14
+ *
15
+ * The rules identify what is **not** media rather than demanding proof of what
16
+ * is. The two mistakes are not equal: a false rejection costs a prefetch and the
17
+ * track still plays from its origin, while a false acceptance breaks the track
18
+ * until the cache is cleared by hand.
19
+ *
20
+ * Mirrored rule for rule by `MediaResponseCheck.swift` on iOS.
21
+ */
22
+ internal object MediaResponseCheck {
23
+
24
+ /**
25
+ * Why a response was refused. Each case carries what was judged so a caller's
26
+ * message names the response rather than just the verdict.
27
+ */
28
+ sealed interface Rejection {
29
+ /** The declared type is a markup or JSON document, which no audio container is. */
30
+ data class MarkupContentType(val contentType: String) : Rejection
31
+
32
+ /**
33
+ * The declared type is text of some other kind and the body carries no
34
+ * recognised container signature. `text/plain` is not refused on the label
35
+ * alone — media servers do mislabel audio — so the body is given the chance
36
+ * to prove itself.
37
+ */
38
+ data class TextWithoutMediaSignature(val contentType: String) : Rejection
39
+
40
+ /** No usable declared type, and the body begins as a markup or JSON document. */
41
+ data object MarkupBody : Rejection
42
+ }
43
+
44
+ /** Enough of a body to carry any container signature read below. */
45
+ const val HEAD_BYTES: Int = 64
46
+
47
+ /**
48
+ * Types that are documents, never audio. Narrow on purpose:
49
+ * `application/octet-stream` and `text/plain` are missing because servers
50
+ * serve real audio under both.
51
+ */
52
+ private val MARKUP_TYPES = setOf(
53
+ "text/html", "application/xhtml+xml",
54
+ "application/xml", "text/xml",
55
+ "application/json", "text/json",
56
+ )
57
+
58
+ private val SIGNATURE_PREFIXES = listOf(
59
+ "ID3", "fLaC", "OggS", "RIFF", "FORM", "caff", "#!AMR", "#EXTM3U",
60
+ )
61
+
62
+ /**
63
+ * The verdict for a response, or null to accept it.
64
+ *
65
+ * [head] is the first bytes of the body — [HEAD_BYTES] is plenty for every
66
+ * signature below. [headIsStartOfResource] is false when the request carried a
67
+ * range, and then only the declared type is judged: a body that begins part
68
+ * way into a file says nothing about the file's first bytes.
69
+ */
70
+ fun rejection(
71
+ contentType: String?,
72
+ head: ByteArray,
73
+ headIsStartOfResource: Boolean
74
+ ): Rejection? {
75
+ val declared = normalised(contentType)
76
+ if (declared != null && declared in MARKUP_TYPES) {
77
+ return Rejection.MarkupContentType(declared)
78
+ }
79
+ if (!headIsStartOfResource) return null
80
+ val body = droppingByteOrderMark(head)
81
+ if (declared != null && declared.startsWith("text/")) {
82
+ return if (hasMediaSignature(body)) null else Rejection.TextWithoutMediaSignature(declared)
83
+ }
84
+ return if (beginsAsMarkup(body)) Rejection.MarkupBody else null
85
+ }
86
+
87
+ /**
88
+ * Lowercased type with any `; charset=…` parameter and surrounding space
89
+ * removed. Null for a missing or empty header.
90
+ */
91
+ private fun normalised(contentType: String?): String? {
92
+ if (contentType.isNullOrEmpty()) return null
93
+ return contentType.substringBefore(';').trim().lowercase().ifEmpty { null }
94
+ }
95
+
96
+ /**
97
+ * Drop a byte-order mark so the checks below read the first real byte. A
98
+ * UTF-16 mark matters twice over: `FF FE` also satisfies the MPEG frame sync
99
+ * pattern, so a UTF-16 document would otherwise read as audio.
100
+ */
101
+ private fun droppingByteOrderMark(head: ByteArray): ByteArray {
102
+ if (head.size >= 3 &&
103
+ head[0] == 0xEF.toByte() && head[1] == 0xBB.toByte() && head[2] == 0xBF.toByte()
104
+ ) {
105
+ return head.copyOfRange(3, head.size)
106
+ }
107
+ if (head.size >= 2 && (
108
+ (head[0] == 0xFF.toByte() && head[1] == 0xFE.toByte()) ||
109
+ (head[0] == 0xFE.toByte() && head[1] == 0xFF.toByte())
110
+ )
111
+ ) {
112
+ return head.copyOfRange(2, head.size)
113
+ }
114
+ return head
115
+ }
116
+
117
+ /**
118
+ * Whether the body opens with the magic bytes of a container either platform
119
+ * plays, or with an HLS playlist's own first line.
120
+ */
121
+ private fun hasMediaSignature(body: ByteArray): Boolean {
122
+ if (body.isEmpty()) return false
123
+ // MPEG audio frame sync: eleven set bits across the first two bytes. ADTS
124
+ // AAC shares it.
125
+ if (body.size >= 2 &&
126
+ body[0] == 0xFF.toByte() &&
127
+ (body[1].toInt() and 0xE0) == 0xE0
128
+ ) {
129
+ return true
130
+ }
131
+ if (SIGNATURE_PREFIXES.any { startsWith(body, it, 0) }) return true
132
+ // ISO base media (MP4 / M4A / ALAC): a box length, then the type.
133
+ if (body.size >= 8 && startsWith(body, "ftyp", 4)) return true
134
+ return false
135
+ }
136
+
137
+ /**
138
+ * Whether the body opens as an XML / HTML document or a JSON value. No
139
+ * container above begins with either character.
140
+ */
141
+ private fun beginsAsMarkup(body: ByteArray): Boolean {
142
+ val first = body.firstOrNull { !isAsciiSpace(it) } ?: return false
143
+ return first == '<'.code.toByte() || first == '{'.code.toByte()
144
+ }
145
+
146
+ private fun isAsciiSpace(b: Byte): Boolean =
147
+ b == 0x20.toByte() || b == 0x09.toByte() || b == 0x0A.toByte() || b == 0x0D.toByte()
148
+
149
+ private fun startsWith(body: ByteArray, ascii: String, at: Int): Boolean {
150
+ if (body.size < at + ascii.length) return false
151
+ for (i in ascii.indices) {
152
+ if (body[at + i] != ascii[i].code.toByte()) return false
153
+ }
154
+ return true
155
+ }
156
+ }
@@ -0,0 +1,165 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.net.Uri
4
+ import androidx.media3.common.C
5
+ import androidx.media3.common.PlaybackException
6
+ import androidx.media3.common.util.UnstableApi
7
+ import androidx.media3.datasource.DataSource
8
+ import androidx.media3.datasource.DataSpec
9
+ import androidx.media3.datasource.HttpDataSource
10
+ import androidx.media3.datasource.TransferListener
11
+
12
+ /**
13
+ * Wraps an HTTP-capable [DataSource.Factory] so a response that is a document
14
+ * rather than media fails before any of it is returned.
15
+ *
16
+ * Installed on the prefetcher's upstream only. Media3 writes to its cache sink
17
+ * only after this `read` returns, so throwing instead of returning keeps the
18
+ * body out of the cache — and the playback path is left alone, because there a
19
+ * refusal would be an unplayable track rather than a prefetch that did not
20
+ * happen. Playback cannot poison the cache either: its `CacheDataSource` is
21
+ * built read-only.
22
+ *
23
+ * Transparent for a source with no response headers (file:// / asset://):
24
+ * those carry no `Content-Type` and are not the case this exists for.
25
+ */
26
+ @UnstableApi
27
+ internal class MediaValidatingDataSourceFactory(
28
+ private val upstream: DataSource.Factory
29
+ ) : DataSource.Factory {
30
+ override fun createDataSource(): DataSource =
31
+ MediaValidatingDataSource(upstream.createDataSource())
32
+ }
33
+
34
+ @UnstableApi
35
+ internal class MediaValidatingDataSource(
36
+ private val delegate: DataSource
37
+ ) : DataSource {
38
+
39
+ private var dataSpec: DataSpec? = null
40
+ private var contentType: String? = null
41
+ private var isHttp = false
42
+ private var startsAtResourceStart = true
43
+
44
+ /**
45
+ * Bytes read from the delegate to judge the body, held back until the verdict
46
+ * is in and then served from here before the delegate is read again. Null once
47
+ * drained — every later read passes straight through.
48
+ */
49
+ private var head: ByteArray? = null
50
+ private var headOffset = 0
51
+ private var bodyJudged = false
52
+
53
+ override fun addTransferListener(transferListener: TransferListener) {
54
+ delegate.addTransferListener(transferListener)
55
+ }
56
+
57
+ override fun open(dataSpec: DataSpec): Long {
58
+ this.dataSpec = dataSpec
59
+ head = null
60
+ headOffset = 0
61
+ bodyJudged = false
62
+ val length = delegate.open(dataSpec)
63
+ // The scheme says whether this is a remote source. Response headers cannot:
64
+ // an HTTP answer that carries no `Content-Type` has none, and that is
65
+ // precisely a body this has to judge. Casting to `HttpDataSource` cannot
66
+ // either — a wrapper is a `DataSource`, so the cast misses whenever one
67
+ // sits between this and the HTTP source.
68
+ isHttp = dataSpec.uri.scheme?.lowercase() in setOf("http", "https")
69
+ if (!isHttp) {
70
+ bodyJudged = true
71
+ return length
72
+ }
73
+ // `responseHeaders` is declared on `DataSource`, so it reads through a
74
+ // wrapper. Header names are case-insensitive and not every server or proxy
75
+ // normalises them.
76
+ contentType = delegate.responseHeaders.entries
77
+ .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
78
+ ?.value?.firstOrNull()
79
+ startsAtResourceStart = dataSpec.position == 0L
80
+ // A declared document type is refused here, before a byte is read.
81
+ MediaResponseCheck.rejection(
82
+ contentType = contentType,
83
+ head = EMPTY,
84
+ headIsStartOfResource = false
85
+ )?.let { throw rejected(it, dataSpec, HttpDataSource.HttpDataSourceException.TYPE_OPEN) }
86
+ return length
87
+ }
88
+
89
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
90
+ if (!bodyJudged) judgeBody()
91
+ val pending = head
92
+ if (pending != null) {
93
+ val remaining = pending.size - headOffset
94
+ val take = minOf(remaining, length)
95
+ pending.copyInto(buffer, offset, headOffset, headOffset + take)
96
+ headOffset += take
97
+ if (headOffset == pending.size) head = null
98
+ return take
99
+ }
100
+ return delegate.read(buffer, offset, length)
101
+ }
102
+
103
+ /**
104
+ * Pull enough of the body from the delegate to read a container signature,
105
+ * judge it, and keep it for [read] to serve. Nothing reaches the caller — and
106
+ * so nothing reaches the cache — until this has run.
107
+ */
108
+ private fun judgeBody() {
109
+ bodyJudged = true
110
+ if (!isHttp) return
111
+ val scratch = ByteArray(MediaResponseCheck.HEAD_BYTES)
112
+ var filled = 0
113
+ while (filled < scratch.size) {
114
+ val read = delegate.read(scratch, filled, scratch.size - filled)
115
+ // A zero-length read would otherwise spin here; treat it as nothing more
116
+ // to judge, the same as the end of the input.
117
+ if (read == C.RESULT_END_OF_INPUT || read == 0) break
118
+ filled += read
119
+ }
120
+ if (filled == 0) return
121
+ val body = if (filled == scratch.size) scratch else scratch.copyOf(filled)
122
+ MediaResponseCheck.rejection(
123
+ contentType = contentType,
124
+ head = body,
125
+ headIsStartOfResource = startsAtResourceStart
126
+ )?.let { throw rejected(it, dataSpec, HttpDataSource.HttpDataSourceException.TYPE_READ) }
127
+ head = body
128
+ headOffset = 0
129
+ }
130
+
131
+ private fun rejected(
132
+ rejection: MediaResponseCheck.Rejection,
133
+ spec: DataSpec?,
134
+ type: Int
135
+ ): HttpDataSource.HttpDataSourceException {
136
+ val detail = when (rejection) {
137
+ is MediaResponseCheck.Rejection.MarkupContentType ->
138
+ "declared type ${rejection.contentType} is a document, not media"
139
+ is MediaResponseCheck.Rejection.TextWithoutMediaSignature ->
140
+ "declared type ${rejection.contentType} with no recognised container signature"
141
+ MediaResponseCheck.Rejection.MarkupBody ->
142
+ "body begins as a markup or JSON document"
143
+ }
144
+ return HttpDataSource.HttpDataSourceException(
145
+ "Response is not media: $detail",
146
+ spec ?: DataSpec(Uri.EMPTY),
147
+ PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
148
+ type
149
+ )
150
+ }
151
+
152
+ override fun getUri(): Uri? = delegate.uri
153
+
154
+ override fun close() {
155
+ head = null
156
+ headOffset = 0
157
+ delegate.close()
158
+ }
159
+
160
+ override fun getResponseHeaders(): Map<String, List<String>> = delegate.responseHeaders
161
+
162
+ private companion object {
163
+ val EMPTY = ByteArray(0)
164
+ }
165
+ }
@@ -4,7 +4,6 @@ import android.net.Uri
4
4
  import androidx.media3.common.util.UnstableApi
5
5
  import androidx.media3.datasource.DataSource
6
6
  import androidx.media3.datasource.DataSpec
7
- import androidx.media3.datasource.HttpDataSource
8
7
  import androidx.media3.datasource.TransferListener
9
8
 
10
9
  /**
@@ -17,8 +16,8 @@ import androidx.media3.datasource.TransferListener
17
16
  * type observed during prefetch and let the playback path pass it
18
17
  * to Media3 via [androidx.media3.common.MediaItem.Builder.setMimeType].
19
18
  *
20
- * The wrapper is transparent for non-HTTP underlying sources (file://,
21
- * asset://) it forwards every call through and skips MIME capture.
19
+ * Only the prefetch path builds this, and its window holds http/https URLs
20
+ * alone, so every response it sees is a server's.
22
21
  */
23
22
  @UnstableApi
24
23
  internal class MimeCapturingDataSourceFactory(
@@ -41,12 +40,13 @@ internal class MimeCapturingDataSource(
41
40
 
42
41
  override fun open(dataSpec: DataSpec): Long {
43
42
  val length = delegate.open(dataSpec)
44
- val httpDelegate = delegate as? HttpDataSource ?: return length
45
- // HTTP headers are case-insensitive; Media3 normalises but
46
- // some servers / proxies emit lowercase. Accept either.
47
- val contentType = httpDelegate.responseHeaders["Content-Type"]?.firstOrNull()
48
- ?: httpDelegate.responseHeaders["content-type"]?.firstOrNull()
49
- onMime(dataSpec.uri.toString(), contentType)
43
+ // `responseHeaders` is declared on `DataSource`, so this reads through
44
+ // whatever wraps the HTTP source casting to `HttpDataSource` would miss,
45
+ // because a wrapper is a `DataSource`.
46
+ // Reported even when null: the sink resolves a declared type first and
47
+ // falls back to the URL's extension, so a response with no `Content-Type`
48
+ // still gets a usable type recorded.
49
+ onMime(dataSpec.uri.toString(), contentTypeOf(delegate.responseHeaders))
50
50
  return length
51
51
  }
52
52
 
@@ -61,4 +61,13 @@ internal class MimeCapturingDataSource(
61
61
 
62
62
  override fun getResponseHeaders(): Map<String, List<String>> =
63
63
  delegate.responseHeaders
64
+
65
+ private companion object {
66
+ /** HTTP header names are case-insensitive; some servers and proxies emit
67
+ * lowercase and Media3 does not always normalise. */
68
+ fun contentTypeOf(headers: Map<String, List<String>>): String? =
69
+ headers.entries
70
+ .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
71
+ ?.value?.firstOrNull()
72
+ }
64
73
  }
@@ -43,9 +43,17 @@ internal object PlaybackErrorMapping {
43
43
  PlaybackErrorCode.AUTH_ERROR
44
44
  PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED ->
45
45
  PlaybackErrorCode.CLEARTEXT_NOT_PERMITTED
46
- PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
47
- PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE ->
46
+ PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND ->
48
47
  PlaybackErrorCode.SOURCE_UNREACHABLE
48
+ PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE ->
49
+ // The source answered, and what it sent is not media — a server
50
+ // reporting an API-level failure as a successful response, or a proxy
51
+ // serving an error page. Not SOURCE_UNREACHABLE: it was reached, and
52
+ // calling it unreachable invites a retry that cannot change the answer.
53
+ // Not HTTP_ERROR either: the status was a success. What is wrong is the
54
+ // payload, which is the same thing iOS reports when AVFoundation fails to
55
+ // parse the identical body.
56
+ PlaybackErrorCode.FILE_FORMAT_NOT_RECOGNIZED
49
57
  PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
50
58
  PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
51
59
  PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,