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
|
@@ -115,7 +115,15 @@ class LookaheadCache(
|
|
|
115
115
|
* surfacing a load error.
|
|
116
116
|
*/
|
|
117
117
|
fun wrapDataSourceFactory(upstream: DataSource.Factory): DataSource.Factory {
|
|
118
|
-
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
45
|
-
// HTTP
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|