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.
Files changed (66) hide show
  1. package/android/build.gradle +2 -1
  2. package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
  3. package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
  4. package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
  5. package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
  6. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  7. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
  8. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
  9. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  10. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  11. package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
  12. package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
  13. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
  14. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
  15. package/lib/commonjs/web/browser/BrowserManager.js +24 -4
  16. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  17. package/lib/commonjs/web/browser/SearchManager.js +12 -12
  18. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  19. package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
  20. package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
  21. package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
  22. package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
  23. package/lib/module/web/NativeAudioBrowser.js +163 -50
  24. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  25. package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
  26. package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
  27. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  28. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  29. package/lib/module/web/TrackPlayer/Player.js +232 -39
  30. package/lib/module/web/TrackPlayer/Player.js.map +1 -1
  31. package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
  32. package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
  33. package/lib/module/web/browser/BrowserManager.js +24 -4
  34. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  35. package/lib/module/web/browser/SearchManager.js +12 -12
  36. package/lib/module/web/browser/SearchManager.js.map +1 -1
  37. package/lib/module/web/http/RequestConfigBuilder.js +110 -27
  38. package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
  39. package/lib/module/web/http/followMediaRedirect.js +215 -0
  40. package/lib/module/web/http/followMediaRedirect.js.map +1 -0
  41. package/lib/typescript/src/types/browser.d.ts +13 -4
  42. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/types/browser.ts +13 -4
  45. package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
  46. package/src/web/NativeAudioBrowser.ts +193 -60
  47. package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
  48. package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
  49. package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
  50. package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
  51. package/src/web/TrackPlayer/Player.load.test.ts +137 -1
  52. package/src/web/TrackPlayer/Player.ts +256 -37
  53. package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
  54. package/src/web/__tests__/lifecycle.test.ts +345 -0
  55. package/src/web/__tests__/lifecycleHarness.ts +351 -0
  56. package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
  57. package/src/web/__tests__/loadSupersession.test.ts +175 -0
  58. package/src/web/__tests__/retry.test.ts +169 -0
  59. package/src/web/browser/BrowserManager.ts +26 -4
  60. package/src/web/browser/SearchManager.ts +16 -16
  61. package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
  62. package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
  63. package/src/web/http/RequestConfigBuilder.test.ts +241 -1
  64. package/src/web/http/RequestConfigBuilder.ts +140 -33
  65. package/src/web/http/followMediaRedirect.test.ts +289 -0
  66. package/src/web/http/followMediaRedirect.ts +253 -0
@@ -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
- DefaultHttpDataSource.Factory().apply {
100
- // Deliberately NOT setUserAgent(): DefaultHttpDataSource applies the factory
101
- // user-agent as a FINAL setRequestProperty("User-Agent", …) that runs after —
102
- // and overrides — the per-request DataSpec headers. That would clobber the
103
- // User-Agent TransformingDataSource sets per request (the whole point of the
104
- // per-request userAgent override). Supplying the same value as a default
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 resolveMediaUrl(src) {
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.resolveMediaUrl(src, request, media);
484
+ return _RequestConfigBuilder.RequestConfigBuilder.resolveMediaRequest(src, request, media, track);
464
485
  }
465
486
 
466
487
  /**
467
- * Reload from the queue's own entry, whose `src` is still unresolved —
468
- * `current` holds the resolved track, and re-feeding it into `load()` would
469
- * re-resolve the resolved URL (double-applying a `transform`) and replace
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
- reloadCurrent() {
492
+ trackToReload() {
473
493
  const index = this.queue.currentIndex;
474
- const sourceTrack = index !== undefined ? this.queue.getTrack(index) : undefined;
475
- if (sourceTrack) {
476
- this.load(sourceTrack);
477
- } else {
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
- // Match Android: load() modifies the queue.
488
- // If queue is empty, add the track. If queue has items, replace at currentIndex.
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
- } else if (this.queue.currentIndex !== undefined && this.queue.getTrack(this.queue.currentIndex) !== track) {
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
- // Set loading flag early so seekTo() calls during async URL resolution
503
- // are captured as pending seeks rather than silently dropped
504
- this._loadInProgress = true;
505
- this._pendingSeek = undefined;
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
- // Resolve the media URL before loading (async but we don't await)
508
- const loadId = ++this.currentLoadId;
509
- const doLoad = async () => {
510
- const resolvedTrack = track.src ? {
511
- ...track,
512
- src: await this.resolveMediaUrl(track.src)
513
- } : track;
514
-
515
- // A newer load() was called while resolving — discard this stale result
516
- if (loadId !== this.currentLoadId) {
517
- return;
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
- // Update now playing metadata
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
- // Only the active load may surface a resolution failure.
544
- if (loadId !== this.currentLoadId) return;
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
- // Invalidate any in-flight load: its post-await continuation would call
708
- // super.load(), whose first line re-arms the player (_isStopped = false)
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
- return;
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