react-native-audio-browser 0.2.0-next.18 → 0.2.0-next.19
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/build.gradle +2 -1
- package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
- package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
- package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
- package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
- package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +24 -4
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +12 -12
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
- package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
- package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
- package/lib/module/web/NativeAudioBrowser.js +163 -50
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
- package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/module/web/TrackPlayer/Player.js +232 -39
- package/lib/module/web/TrackPlayer/Player.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +24 -4
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +12 -12
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/module/web/http/RequestConfigBuilder.js +110 -27
- package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/module/web/http/followMediaRedirect.js +215 -0
- package/lib/module/web/http/followMediaRedirect.js.map +1 -0
- package/lib/typescript/src/types/browser.d.ts +13 -4
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/types/browser.ts +13 -4
- package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
- package/src/web/NativeAudioBrowser.ts +193 -60
- package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
- package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
- package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
- package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
- package/src/web/TrackPlayer/Player.load.test.ts +137 -1
- package/src/web/TrackPlayer/Player.ts +256 -37
- package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
- package/src/web/__tests__/lifecycle.test.ts +345 -0
- package/src/web/__tests__/lifecycleHarness.ts +351 -0
- package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
- package/src/web/__tests__/loadSupersession.test.ts +175 -0
- package/src/web/__tests__/retry.test.ts +169 -0
- package/src/web/browser/BrowserManager.ts +26 -4
- package/src/web/browser/SearchManager.ts +16 -16
- package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
- package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
- package/src/web/http/RequestConfigBuilder.test.ts +241 -1
- package/src/web/http/RequestConfigBuilder.ts +140 -33
- package/src/web/http/followMediaRedirect.test.ts +289 -0
- package/src/web/http/followMediaRedirect.ts +253 -0
package/android/build.gradle
CHANGED
|
@@ -199,6 +199,7 @@ dependencies {
|
|
|
199
199
|
implementation("androidx.media3:media3-exoplayer-dash:${media3Version}")
|
|
200
200
|
implementation("androidx.media3:media3-exoplayer-smoothstreaming:${media3Version}")
|
|
201
201
|
implementation("androidx.media3:media3-common:${media3Version}")
|
|
202
|
+
implementation("androidx.media3:media3-datasource-okhttp:${media3Version}")
|
|
202
203
|
|
|
203
204
|
implementation "com.google.guava:guava:33.0.0-android"
|
|
204
205
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0"
|
|
@@ -243,4 +244,4 @@ if (isNewArchitectureEnabled()) {
|
|
|
243
244
|
libraryName = "AudioBrowser"
|
|
244
245
|
codegenJavaPackageName = "com.audiobrowser"
|
|
245
246
|
}
|
|
246
|
-
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package com.audiobrowser.player
|
|
2
|
+
|
|
3
|
+
import okhttp3.HttpUrl
|
|
4
|
+
import okhttp3.Interceptor
|
|
5
|
+
import okhttp3.Request
|
|
6
|
+
import okhttp3.Response
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Drops the caller's headers once a redirect leaves the origin.
|
|
10
|
+
*
|
|
11
|
+
* OkHttp does this for `Authorization` alone — RetryAndFollowUpInterceptor.buildRedirectRequest is
|
|
12
|
+
* a single `removeHeader("Authorization")` — so every other configured header, an `X-Api-Key` say,
|
|
13
|
+
* still reaches whatever host the first one named. iOS forwards none across a hop and web clears
|
|
14
|
+
* them too; this brings Android in line.
|
|
15
|
+
*
|
|
16
|
+
* Installed as a **network** interceptor, which runs once per hop. `chain.call().request()` is the
|
|
17
|
+
* call's original request, so the comparison is always against the URL the credential was addressed
|
|
18
|
+
* to rather than the hop before.
|
|
19
|
+
*
|
|
20
|
+
* Scoped to the redirect hop, and only that. An HLS segment or key fetch is its own Call, already
|
|
21
|
+
* addressed to the absolute CDN URL, and [TransformingDataSource] deliberately re-applies the
|
|
22
|
+
* config's headers to it — authenticated segments are a supported shape, and web does the same
|
|
23
|
+
* through Shaka's request filter. So this closes the leak of handing a credential to a host the
|
|
24
|
+
* caller never named; it is not a rule that the credential stays on one origin.
|
|
25
|
+
*/
|
|
26
|
+
internal object CrossOriginHeaderStripper : Interceptor {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Headers that describe the transport rather than the caller. OkHttp and media3 set these per hop
|
|
30
|
+
* — `Range` carries the seek, `Host` and `Connection` are the connection's own — so the request
|
|
31
|
+
* breaks without them. Everything else on a media request came from the media config, and is the
|
|
32
|
+
* caller's to send only to the host it addressed.
|
|
33
|
+
*
|
|
34
|
+
* `Icy-MetaData` is media3's, not the caller's: `ProgressiveMediaPeriod` asks for Shoutcast
|
|
35
|
+
* metadata through `ICY_METADATA_HEADERS` on the DataSpec — the same channel
|
|
36
|
+
* [TransformingDataSource] puts the config's headers on, so nothing downstream can tell them
|
|
37
|
+
* apart. Dropping it on a hop costs the station's `icy-metaint`, so `IcyDataSource` is never
|
|
38
|
+
* installed and `onMetadata` stops firing: live "now playing" disappears on exactly the
|
|
39
|
+
* redirecting streams this interceptor exists for, and the interleaved metadata bytes stay in the
|
|
40
|
+
* audio as audible chirping. It is a request for a response format, never a credential.
|
|
41
|
+
*/
|
|
42
|
+
private val TRANSPORT_HEADERS =
|
|
43
|
+
setOf(
|
|
44
|
+
"accept",
|
|
45
|
+
"accept-encoding",
|
|
46
|
+
"connection",
|
|
47
|
+
"content-length",
|
|
48
|
+
"host",
|
|
49
|
+
"icy-metadata",
|
|
50
|
+
"range",
|
|
51
|
+
"transfer-encoding",
|
|
52
|
+
"user-agent",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
56
|
+
val request = chain.request()
|
|
57
|
+
val addressed = chain.call().request().url
|
|
58
|
+
return chain.proceed(
|
|
59
|
+
if (sameOrigin(addressed, request.url)) request else stripCallerHeaders(request)
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Scheme, host and port — the same origin test `followMediaRedirect` applies on web. */
|
|
64
|
+
internal fun sameOrigin(a: HttpUrl, b: HttpUrl): Boolean =
|
|
65
|
+
a.scheme == b.scheme && a.host == b.host && a.port == b.port
|
|
66
|
+
|
|
67
|
+
/** The request with every caller-supplied header removed. */
|
|
68
|
+
internal fun stripCallerHeaders(request: Request): Request =
|
|
69
|
+
request
|
|
70
|
+
.newBuilder()
|
|
71
|
+
.apply {
|
|
72
|
+
request.headers.names().forEach { name ->
|
|
73
|
+
if (name.lowercase() !in TRANSPORT_HEADERS) removeHeader(name)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
.build()
|
|
77
|
+
}
|
|
@@ -4,10 +4,10 @@ import android.content.Context
|
|
|
4
4
|
import androidx.media3.common.MediaItem
|
|
5
5
|
import androidx.media3.datasource.DataSource
|
|
6
6
|
import androidx.media3.datasource.DefaultDataSource
|
|
7
|
-
import androidx.media3.datasource.DefaultHttpDataSource
|
|
8
7
|
import androidx.media3.datasource.TransferListener
|
|
9
8
|
import androidx.media3.datasource.cache.CacheDataSource
|
|
10
9
|
import androidx.media3.datasource.cache.SimpleCache
|
|
10
|
+
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
11
11
|
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
|
12
12
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
13
13
|
import androidx.media3.exoplayer.source.MediaSource
|
|
@@ -16,6 +16,8 @@ import androidx.media3.extractor.DefaultExtractorsFactory
|
|
|
16
16
|
import com.audiobrowser.model.RetryPolicy
|
|
17
17
|
import com.margelo.nitro.audiobrowser.MediaRequestConfig
|
|
18
18
|
import java.io.IOException
|
|
19
|
+
import java.util.concurrent.TimeUnit
|
|
20
|
+
import okhttp3.OkHttpClient
|
|
19
21
|
|
|
20
22
|
class MediaFactory(
|
|
21
23
|
private val context: Context,
|
|
@@ -31,6 +33,20 @@ class MediaFactory(
|
|
|
31
33
|
|
|
32
34
|
companion object {
|
|
33
35
|
private const val DEFAULT_USER_AGENT = "react-native-audio-browser"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The client media requests go out on. One per process so connections are reused; not
|
|
39
|
+
* `HttpClient`'s browse client, whose read timeout is sized for JSON and would cut a long media
|
|
40
|
+
* read short. The timeouts are media3's own defaults, so stall detection and
|
|
41
|
+
* [RetryLoadErrorHandlingPolicy] behave as they did before.
|
|
42
|
+
*/
|
|
43
|
+
private val mediaHttpClient: OkHttpClient by lazy {
|
|
44
|
+
OkHttpClient.Builder()
|
|
45
|
+
.connectTimeout(8, TimeUnit.SECONDS)
|
|
46
|
+
.readTimeout(8, TimeUnit.SECONDS)
|
|
47
|
+
.addNetworkInterceptor(CrossOriginHeaderStripper)
|
|
48
|
+
.build()
|
|
49
|
+
}
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
private val extractorsFactory =
|
|
@@ -95,17 +111,19 @@ class MediaFactory(
|
|
|
95
111
|
// ExoPlayer's IO thread. This prevents deadlocks when the JS thread is blocked by
|
|
96
112
|
// synchronous Nitro calls (e.g., seekTo) while a media transform callback needs the
|
|
97
113
|
// JS thread to resolve.
|
|
114
|
+
|
|
115
|
+
// OkHttp rather than DefaultHttpDataSource, for how each treats a redirect.
|
|
116
|
+
// DefaultHttpDataSource re-applies the request headers on every hop, handing
|
|
117
|
+
// an Authorization header to whatever host the first one named. OkHttp drops
|
|
118
|
+
// that one header itself, and [CrossOriginHeaderStripper] drops the rest.
|
|
98
119
|
val httpFactory =
|
|
99
|
-
|
|
100
|
-
// Deliberately NOT setUserAgent():
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// request property keeps the default for un-overridden requests while letting
|
|
106
|
-
// the per-request DataSpec header win.
|
|
120
|
+
OkHttpDataSource.Factory(mediaHttpClient).apply {
|
|
121
|
+
// Deliberately NOT setUserAgent(): it is applied last via addHeader(),
|
|
122
|
+
// which appends, so a request already carrying one would send two. As a
|
|
123
|
+
// default request property it is merged under dataSpec.httpRequestHeaders
|
|
124
|
+
// and applied with header(), letting TransformingDataSource's per-request
|
|
125
|
+
// override win.
|
|
107
126
|
setDefaultRequestProperties(mapOf("User-Agent" to DEFAULT_USER_AGENT))
|
|
108
|
-
setAllowCrossProtocolRedirects(true)
|
|
109
127
|
// Connect transfer listener for bandwidth measurement
|
|
110
128
|
transferListener?.let { setTransferListener(it) }
|
|
111
129
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
package com.audiobrowser.player
|
|
2
|
+
|
|
3
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
4
|
+
import okhttp3.Request
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertFalse
|
|
7
|
+
import org.junit.Assert.assertNull
|
|
8
|
+
import org.junit.Assert.assertTrue
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* OkHttp drops `Authorization` across a cross-origin redirect by itself; these cover the rest,
|
|
13
|
+
* which it forwards and [CrossOriginHeaderStripper.stripCallerHeaders] removes.
|
|
14
|
+
*/
|
|
15
|
+
class CrossOriginHeaderStripperTest {
|
|
16
|
+
|
|
17
|
+
private val api = "https://api.example.com/stream/7".toHttpUrl()
|
|
18
|
+
|
|
19
|
+
@Test
|
|
20
|
+
fun `same origin ignores the path`() {
|
|
21
|
+
assertTrue(
|
|
22
|
+
CrossOriginHeaderStripper.sameOrigin(
|
|
23
|
+
api,
|
|
24
|
+
"https://api.example.com/signed/track.mp3".toHttpUrl(),
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `a different host, port or scheme is a different origin`() {
|
|
31
|
+
assertFalse(
|
|
32
|
+
CrossOriginHeaderStripper.sameOrigin(api, "https://cdn.example.com/track.mp3".toHttpUrl())
|
|
33
|
+
)
|
|
34
|
+
assertFalse(
|
|
35
|
+
CrossOriginHeaderStripper.sameOrigin(
|
|
36
|
+
api,
|
|
37
|
+
"https://api.example.com:8443/track.mp3".toHttpUrl(),
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
assertFalse(
|
|
41
|
+
CrossOriginHeaderStripper.sameOrigin(api, "http://api.example.com/track.mp3".toHttpUrl())
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Test
|
|
46
|
+
fun `strips every caller header, whatever its name`() {
|
|
47
|
+
val request =
|
|
48
|
+
Request.Builder()
|
|
49
|
+
.url(api)
|
|
50
|
+
.header("Authorization", "Bearer token")
|
|
51
|
+
.header("X-Api-Key", "k")
|
|
52
|
+
.header("Cookie", "session=abc")
|
|
53
|
+
.build()
|
|
54
|
+
|
|
55
|
+
val stripped = CrossOriginHeaderStripper.stripCallerHeaders(request)
|
|
56
|
+
|
|
57
|
+
assertNull(stripped.header("Authorization"))
|
|
58
|
+
assertNull(stripped.header("X-Api-Key"))
|
|
59
|
+
assertNull(stripped.header("Cookie"))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun `keeps the transport headers the hop needs`() {
|
|
64
|
+
val request =
|
|
65
|
+
Request.Builder()
|
|
66
|
+
.url(api)
|
|
67
|
+
.header("Range", "bytes=1024-")
|
|
68
|
+
.header("accept-encoding", "identity")
|
|
69
|
+
.header("User-Agent", "react-native-audio-browser")
|
|
70
|
+
.header("X-Api-Key", "k")
|
|
71
|
+
.build()
|
|
72
|
+
|
|
73
|
+
val stripped = CrossOriginHeaderStripper.stripCallerHeaders(request)
|
|
74
|
+
|
|
75
|
+
// a dropped Range would restart the read from zero on every seek past a redirect
|
|
76
|
+
assertEquals("bytes=1024-", stripped.header("Range"))
|
|
77
|
+
assertEquals("identity", stripped.header("Accept-Encoding"))
|
|
78
|
+
assertEquals("react-native-audio-browser", stripped.header("User-Agent"))
|
|
79
|
+
assertNull(stripped.header("X-Api-Key"))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test
|
|
83
|
+
fun `keeps media3's ICY metadata request across a hop`() {
|
|
84
|
+
// ProgressiveMediaPeriod asks for Shoutcast metadata on the DataSpec, the same channel the
|
|
85
|
+
// config's headers arrive on. Dropping it costs icy-metaint, so onMetadata never fires again.
|
|
86
|
+
val request =
|
|
87
|
+
Request.Builder().url(api).header("Icy-MetaData", "1").header("X-Api-Key", "k").build()
|
|
88
|
+
|
|
89
|
+
val stripped = CrossOriginHeaderStripper.stripCallerHeaders(request)
|
|
90
|
+
|
|
91
|
+
assertEquals("1", stripped.header("Icy-MetaData"))
|
|
92
|
+
assertNull(stripped.header("X-Api-Key"))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -10,6 +10,7 @@ var _classifyTrackNavigation = require("./browser/classifyTrackNavigation");
|
|
|
10
10
|
var _FavoriteManager = require("./browser/FavoriteManager");
|
|
11
11
|
var _NavigationErrorManager = require("./browser/NavigationErrorManager");
|
|
12
12
|
var _SearchManager = require("./browser/SearchManager");
|
|
13
|
+
var _followMediaRedirect = require("./http/followMediaRedirect");
|
|
13
14
|
var _HttpClient = require("./http/HttpClient");
|
|
14
15
|
var _RequestConfigBuilder = require("./http/RequestConfigBuilder");
|
|
15
16
|
var _NowPlayingManager = require("./player/NowPlayingManager");
|
|
@@ -44,7 +45,6 @@ class NativeAudioBrowser extends _TrackPlayer.QueuePlayer {
|
|
|
44
45
|
// Managers
|
|
45
46
|
|
|
46
47
|
// Player state
|
|
47
|
-
currentLoadId = 0;
|
|
48
48
|
progressTimer = new _PlaybackTimer.PlaybackTimer();
|
|
49
49
|
intervalTimer = new _PlaybackTimer.PlaybackTimer();
|
|
50
50
|
_online = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
@@ -211,6 +211,13 @@ class NativeAudioBrowser extends _TrackPlayer.QueuePlayer {
|
|
|
211
211
|
this.onPlaybackError({
|
|
212
212
|
error: newState.error
|
|
213
213
|
});
|
|
214
|
+
} else if (oldState.state === 'error') {
|
|
215
|
+
// Leaving the error state clears it, as it does natively. The getter
|
|
216
|
+
// already reads undefined — consumers subscribe to the event, so without
|
|
217
|
+
// this a stale error outlives the next successful load.
|
|
218
|
+
this.onPlaybackError({
|
|
219
|
+
error: undefined
|
|
220
|
+
});
|
|
214
221
|
}
|
|
215
222
|
}
|
|
216
223
|
/**
|
|
@@ -455,68 +462,98 @@ class NativeAudioBrowser extends _TrackPlayer.QueuePlayer {
|
|
|
455
462
|
* Supports the transform callback for URL manipulation.
|
|
456
463
|
* Mirrors Android's MediaFactory.getMediaRequestConfig behavior.
|
|
457
464
|
*/
|
|
458
|
-
async
|
|
465
|
+
async resolveMediaRequest(src, track) {
|
|
466
|
+
// The *resolved* request layer, not `configuration.request`: a
|
|
467
|
+
// resolver-only config would otherwise reach media with no baseUrl and no
|
|
468
|
+
// headers. Browse, search and browse artwork all read it this way too.
|
|
469
|
+
//
|
|
470
|
+
// Caught, because unlike those three this one cannot fail the load: a
|
|
471
|
+
// resolver hiccup — a token refresh timing out — would otherwise take down
|
|
472
|
+
// a track whose `src` is already absolute and public and needed no layer at
|
|
473
|
+
// all. Native degrades the same way, `getMediaRequestConfig` returning null
|
|
474
|
+
// and the original URL playing on.
|
|
475
|
+
let request;
|
|
476
|
+
try {
|
|
477
|
+
request = await this.browserManager.resolvedRequestConfig();
|
|
478
|
+
} catch (e) {
|
|
479
|
+
console.error('Failed to resolve the request layer for media', e);
|
|
480
|
+
}
|
|
459
481
|
const {
|
|
460
|
-
request,
|
|
461
482
|
media
|
|
462
483
|
} = this.browserManager.configuration;
|
|
463
|
-
return _RequestConfigBuilder.RequestConfigBuilder.
|
|
484
|
+
return _RequestConfigBuilder.RequestConfigBuilder.resolveMediaRequest(src, request, media, track);
|
|
464
485
|
}
|
|
465
486
|
|
|
466
487
|
/**
|
|
467
|
-
*
|
|
468
|
-
* `current`
|
|
469
|
-
*
|
|
470
|
-
* the queue entry with it.
|
|
488
|
+
* The queue's own entry, so the queue stays the source of truth for what is
|
|
489
|
+
* playing. `current` preserves the track as queued — the resolved URL is
|
|
490
|
+
* passed to `load()` separately — so either is safe to re-feed.
|
|
471
491
|
*/
|
|
472
|
-
|
|
492
|
+
trackToReload() {
|
|
473
493
|
const index = this.queue.currentIndex;
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
super.reloadCurrent();
|
|
479
|
-
}
|
|
494
|
+
const queued = index !== undefined ? this.queue.getTrack(index) : undefined;
|
|
495
|
+
// The queue entry wins, so it stays the source of truth for what is
|
|
496
|
+
// playing; `current` is the fallback for a player loaded without one.
|
|
497
|
+
return queued ?? super.trackToReload();
|
|
480
498
|
}
|
|
481
|
-
load(track, callback) {
|
|
482
|
-
const element = this.requireElement();
|
|
483
|
-
|
|
484
|
-
// Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
|
|
485
|
-
this.nowPlayingManager.clearNowPlayingOverride();
|
|
486
499
|
|
|
487
|
-
|
|
488
|
-
|
|
500
|
+
/**
|
|
501
|
+
* Makes `track` the queue's current entry, emitting the change.
|
|
502
|
+
*
|
|
503
|
+
* Matches Android, where `load()` modifies the queue: an empty queue takes
|
|
504
|
+
* the track as its only entry, a populated one has its current entry
|
|
505
|
+
* replaced.
|
|
506
|
+
*/
|
|
507
|
+
syncQueueTo(track) {
|
|
489
508
|
if (this.queue.length === 0) {
|
|
490
509
|
this.queue.setTracks([track]);
|
|
491
510
|
this.queue.currentIndex = 0;
|
|
492
511
|
this.emitQueueChanged();
|
|
493
|
-
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (this.queue.currentIndex !== undefined && this.queue.getTrack(this.queue.currentIndex) !== track) {
|
|
494
515
|
this.queue.replaceTrack(this.queue.currentIndex, track);
|
|
495
516
|
this.emitQueueChanged();
|
|
496
517
|
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Everything native does in its media-item transition, all of it before any
|
|
522
|
+
* network work: announce the new track, publish its now-playing metadata,
|
|
523
|
+
* cut the outgoing one, and report `loading`.
|
|
524
|
+
*
|
|
525
|
+
* Android's `onMediaItemTransition` fires at `prepare()` and does exactly
|
|
526
|
+
* this, with its LOADING following from `onEvents`; resolution happens later
|
|
527
|
+
* still, inside `TransformingDataSource.open()`. Doing it here rather than
|
|
528
|
+
* after the URL resolves is what gives the resolution window a state, and
|
|
529
|
+
* keeps `activeTrack` ahead of `loading` — the order a consumer that reads
|
|
530
|
+
* `getActiveTrack()` on the loading edge depends on.
|
|
531
|
+
*/
|
|
532
|
+
transitionTo(track) {
|
|
533
|
+
const element = this.requireElement();
|
|
497
534
|
const lastTrack = this.current;
|
|
498
535
|
const lastPosition = element.currentTime;
|
|
499
536
|
const lastIndex = this.queue.lastIndex;
|
|
500
537
|
const currentIndex = this.queue.currentIndex;
|
|
501
538
|
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
539
|
+
// A re-prepare of the item already current is not a transition, and must
|
|
540
|
+
// not be announced as one: ExoPlayer's `prepare()` does not re-fire
|
|
541
|
+
// `onMediaItemTransition` for an unchanged media item, so a consumer that
|
|
542
|
+
// resets on a track change would have its UI wiped by every retry. The
|
|
543
|
+
// `lastTrack === track` payload was the tell.
|
|
544
|
+
const isTransition = this.current !== track;
|
|
506
545
|
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
super.load(resolvedTrack, loadedTrack => {
|
|
546
|
+
// `current` first, so `getActiveTrack()` already reads the new track inside
|
|
547
|
+
// a handler and so `getNowPlaying()` publishes the new track's metadata. It
|
|
548
|
+
// also stops `getActiveTrackIndex()` — which moved with the queue already,
|
|
549
|
+
// synchronously — from disagreeing with it for the length of the
|
|
550
|
+
// resolution.
|
|
551
|
+
this.current = track;
|
|
552
|
+
|
|
553
|
+
// Separately guarded: a throwing consumer handler must not skip the
|
|
554
|
+
// publish, and neither may fail the load.
|
|
555
|
+
if (isTransition) {
|
|
556
|
+
try {
|
|
520
557
|
this.onPlaybackActiveTrackChanged({
|
|
521
558
|
lastTrack,
|
|
522
559
|
lastPosition,
|
|
@@ -524,24 +561,100 @@ class NativeAudioBrowser extends _TrackPlayer.QueuePlayer {
|
|
|
524
561
|
index: currentIndex,
|
|
525
562
|
track
|
|
526
563
|
});
|
|
527
|
-
|
|
528
|
-
|
|
564
|
+
} catch (error) {
|
|
565
|
+
console.error('Failed to announce the active track change:', error);
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
// Once, here: duration reaches the media session through
|
|
569
|
+
// `updateProgress()` on each tick, so only metadata needs publishing.
|
|
570
|
+
// Android publishes from the same transition handler that announces.
|
|
529
571
|
const nowPlaying = this.getNowPlaying();
|
|
530
572
|
if (nowPlaying) {
|
|
531
573
|
this.publishNowPlaying(nowPlaying);
|
|
532
574
|
}
|
|
575
|
+
} catch (error) {
|
|
576
|
+
console.error('Failed to publish now-playing metadata:', error);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Stop the outgoing track now rather than when Shaka eventually gets a URL.
|
|
581
|
+
// Announcing the new track while the old one is still audible is worse than
|
|
582
|
+
// the silence, and native has no such window — prepare() cuts the old item
|
|
583
|
+
// at the moment it announces. This adds no teardown: `player.load()`
|
|
584
|
+
// already unloads first (shaka player.js:1757-1762). It only moves when.
|
|
585
|
+
// Verified in a real browser to emit no `pause`, so no state comes of it.
|
|
586
|
+
this.requirePlayer().unload().catch(error => console.error('Failed to unload the previous track:', error));
|
|
587
|
+
|
|
588
|
+
// Loading covers the resolution too. Shaka dispatches its own `loading`
|
|
589
|
+
// once `player.load()` is called — after resolution — and the state setter
|
|
590
|
+
// drops that as a no-op. The `buffering` that follows is Shaka's to report.
|
|
591
|
+
this.dispatch({
|
|
592
|
+
type: 'trackLoading'
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
load(track, callback) {
|
|
596
|
+
// Before the queue is touched, so a load before `setupPlayer()` throws
|
|
597
|
+
// rather than half-applying.
|
|
598
|
+
this.requireElement();
|
|
533
599
|
|
|
600
|
+
// Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
|
|
601
|
+
this.nowPlayingManager.clearNowPlayingOverride();
|
|
602
|
+
this.syncQueueTo(track);
|
|
603
|
+
|
|
604
|
+
// Begun here, at the call, so a newer load supersedes this one immediately
|
|
605
|
+
// rather than once its own URL has resolved. Both continuations below carry
|
|
606
|
+
// it, as does `super.load()`.
|
|
607
|
+
const attempt = this.beginLoadAttempt();
|
|
608
|
+
|
|
609
|
+
// Loading ends being stopped. `dispatch()` drops every event while
|
|
610
|
+
// `_isStopped`, to keep Shaka's teardown noise off the stopped state, so
|
|
611
|
+
// the `trackLoading` in `transitionTo` would be swallowed on the stop → play
|
|
612
|
+
// path and the resolution window would report nothing. `super.load()` clears
|
|
613
|
+
// this too, but only after the URL resolves.
|
|
614
|
+
this._isStopped = false;
|
|
615
|
+
|
|
616
|
+
// Set loading flag early so seekTo() calls during async URL resolution
|
|
617
|
+
// are captured as pending seeks rather than silently dropped
|
|
618
|
+
this._loadInProgress = true;
|
|
619
|
+
this._pendingSeek = undefined;
|
|
620
|
+
this.transitionTo(track);
|
|
621
|
+
|
|
622
|
+
// Resolve the media URL before loading (async but we don't await)
|
|
623
|
+
const doLoad = async () => {
|
|
624
|
+
const resolvedRequest = track.src ? await this.resolveMediaRequest(track.src, track) : undefined;
|
|
625
|
+
// Web-only, and forced: a progressive file plays via `mediaElement.src`,
|
|
626
|
+
// which cannot carry headers. Native sends them with the media request
|
|
627
|
+
// and follows the redirect inline, so it needs none of this.
|
|
628
|
+
const resolvedMedia = resolvedRequest ? await (0, _followMediaRedirect.followMediaRedirect)(resolvedRequest.path ?? track.src, resolvedRequest.headers, {
|
|
629
|
+
mediaLayerHeaders: resolvedRequest.mediaLayerHeaders
|
|
630
|
+
}) : undefined;
|
|
631
|
+
|
|
632
|
+
// Something took over while the URL was resolving — discard this result
|
|
633
|
+
if (!attempt.isCurrent) return;
|
|
634
|
+
super.load(track, loadedTrack => {
|
|
534
635
|
// Call the provided callback if any
|
|
535
636
|
if (callback) {
|
|
536
637
|
callback(loadedTrack);
|
|
537
638
|
}
|
|
639
|
+
}, {
|
|
640
|
+
headers: resolvedMedia?.headers,
|
|
641
|
+
src: resolvedMedia?.src,
|
|
642
|
+
attempt
|
|
538
643
|
});
|
|
539
644
|
};
|
|
540
645
|
|
|
541
646
|
// Execute async load without blocking, with error handling
|
|
542
647
|
doLoad().catch(error => {
|
|
543
|
-
//
|
|
544
|
-
|
|
648
|
+
// A backstop with no reachable trigger today, kept because an unhandled
|
|
649
|
+
// rejection would be worse than a dead branch. Every await inside
|
|
650
|
+
// `doLoad` catches internally; `shaka.Player.load` is `async`, so it
|
|
651
|
+
// rejects rather than throwing and its rejection is handled against the
|
|
652
|
+
// attempt inside `Player.load`; and `this.player` is only ever assigned,
|
|
653
|
+
// so `requirePlayer()` cannot start succeeding and then fail mid-load.
|
|
654
|
+
//
|
|
655
|
+
// No attempt check either, for the same reason it would be dead: nothing
|
|
656
|
+
// awaits between the guard above and `super.load()`, so the attempt
|
|
657
|
+
// cannot have changed. Add an await there and this needs the check back.
|
|
545
658
|
this._loadInProgress = false;
|
|
546
659
|
this._pendingSeek = undefined;
|
|
547
660
|
console.error('Error loading track:', error);
|
|
@@ -704,10 +817,8 @@ class NativeAudioBrowser extends _TrackPlayer.QueuePlayer {
|
|
|
704
817
|
this.clearSleepTimerIfFading();
|
|
705
818
|
}
|
|
706
819
|
stop() {
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
// and revives the state out of Stopped.
|
|
710
|
-
this.currentLoadId++;
|
|
820
|
+
// `Player.stop()` cancels the load attempt, so an in-flight load's
|
|
821
|
+
// continuation cannot re-arm the player and revive it out of Stopped.
|
|
711
822
|
super.stop();
|
|
712
823
|
this.clearSleepTimerIfFading();
|
|
713
824
|
}
|
|
@@ -733,11 +844,13 @@ class NativeAudioBrowser extends _TrackPlayer.QueuePlayer {
|
|
|
733
844
|
this.sleepTimer.clear();
|
|
734
845
|
this.pause();
|
|
735
846
|
this.onSleepTimerChanged(null);
|
|
736
|
-
|
|
847
|
+
// Nothing is loading, so the caller must still report the end — the
|
|
848
|
+
// element has stopped and its own `pause` is swallowed as end-of-track.
|
|
849
|
+
return false;
|
|
737
850
|
}
|
|
738
851
|
|
|
739
852
|
// Otherwise proceed with normal track end behavior
|
|
740
|
-
super.onTrackEnded();
|
|
853
|
+
return super.onTrackEnded();
|
|
741
854
|
}
|
|
742
855
|
|
|
743
856
|
// MARK: Queue management
|