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
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.followMediaRedirect = followMediaRedirect;
7
+ /**
8
+ * Resolves a media URL that is authenticated by a header.
9
+ *
10
+ * Shaka only routes manifest requests through its networking engine, where a
11
+ * request filter can attach headers. A progressive file is played by assigning
12
+ * `mediaElement.src`, and a media element cannot send custom headers — so such
13
+ * a URL is unplayable that way. Following the redirect here yields the signed,
14
+ * public URL these endpoints hand back, which needs no auth of its own.
15
+ *
16
+ * Best effort and bounded: anything that fails — including taking too long, see
17
+ * {@link PROBE_TIMEOUT_MS} — falls back to the original URL, so the load fails
18
+ * or succeeds on its own terms rather than ours. It must never be the reason a
19
+ * load hangs: `load()` awaits this before Shaka sees the URL at all.
20
+ *
21
+ * Gated, because the probe is not free: a HEAD at a third-party stream host
22
+ * costs a CORS preflight, and most will refuse it, so an app that never
23
+ * authenticates its media would pay a round trip on every play for a result it
24
+ * always discards. It runs only for headers that look like media credentials
25
+ * ({@link isMediaAuth}), on media a media element actually has to play
26
+ * ({@link isManifestUrl}), and never on a URL the browser resolves locally.
27
+ */
28
+
29
+ /** URLs the browser resolves locally; there is no redirect to follow. */
30
+ const isLocalUrl = url => /^(file|blob|data|mediasource):/i.test(url);
31
+
32
+ /**
33
+ * Extensions Shaka types as a manifest, from its own `EXTENSIONS_TO_MIME_TYPES_`
34
+ * map: HLS, DASH and Smooth Streaming.
35
+ */
36
+ const MANIFEST_EXTENSIONS = new Set(['m3u8', 'mpd', 'ism']);
37
+
38
+ /**
39
+ * Whether Shaka will parse this as a manifest rather than hand it to the media
40
+ * element — in which case the probe has nothing to offer and something to cost.
41
+ *
42
+ * A manifest goes through Shaka's networking engine, where the request filter
43
+ * attaches the headers to the manifest, its segments and any key requests. The
44
+ * probe cannot improve on that, and it actively breaks it: on a cross-origin
45
+ * redirect the probe clears the headers, so the segments that Android still
46
+ * authenticates would go out bare and 401. So this is a correctness fix as much
47
+ * as it is one fewer round trip.
48
+ *
49
+ * Extension-based, and therefore partial — Shaka guesses the same way
50
+ * (`NetworkingUtils.getExtension`), but plenty of HLS endpoints have no
51
+ * extension at all. Those still get probed, which is the status quo rather than
52
+ * a regression. Nothing on `Track` carries a MIME type to do better with.
53
+ */
54
+ const isManifestUrl = url => {
55
+ const path = url.split(/[?#]/)[0] ?? '';
56
+ const filename = path.slice(path.lastIndexOf('/') + 1);
57
+ const pieces = filename.split('.');
58
+ return pieces.length > 1 && MANIFEST_EXTENSIONS.has(pieces[pieces.length - 1].toLowerCase());
59
+ };
60
+ /**
61
+ * How long the probe may take before the original URL is played instead.
62
+ *
63
+ * The same 8s the Android media client allows a connect or a read
64
+ * (`MediaFactory.mediaHttpClient`), which is generous for a HEAD but errs the
65
+ * safe way: a slow-but-working auth endpoint should not be abandoned, since
66
+ * giving up means playing a URL that will 401.
67
+ */
68
+ const PROBE_TIMEOUT_MS = 8_000;
69
+
70
+ /**
71
+ * `AbortSignal.timeout` where it exists (Safari 16+, Chrome 103+, Node 17.3+),
72
+ * else a hand-rolled equivalent, else nothing — an environment with neither
73
+ * still gets the deadline below, just without cancelling the request.
74
+ */
75
+ const timeoutSignal = ms => {
76
+ if (typeof AbortSignal === 'undefined') return undefined;
77
+ if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms);
78
+ if (typeof AbortController === 'undefined') return undefined;
79
+ const controller = new AbortController();
80
+ setTimeout(() => controller.abort(), ms);
81
+ return controller.signal;
82
+ };
83
+
84
+ /**
85
+ * The probe, with a deadline. `load()` awaits this before Shaka is asked to
86
+ * play anything, so an unreachable host would otherwise stall the load
87
+ * indefinitely — no audio, and no error to show for it.
88
+ *
89
+ * Two mechanisms, each with its own job: the signal cancels the request, so a
90
+ * dead host does not hold a connection open behind us, and the race bounds *us*
91
+ * even where the fetch implementation ignores the signal. Whichever fires, the
92
+ * caller treats it as any other probe failure and plays the original URL.
93
+ */
94
+ const fetchWithDeadline = async (doFetch, url, headers, timeoutMs) => {
95
+ let timer;
96
+ try {
97
+ return await Promise.race([doFetch(url, {
98
+ method: 'HEAD',
99
+ headers,
100
+ redirect: 'follow',
101
+ signal: timeoutSignal(timeoutMs)
102
+ }), new Promise((_resolve, reject) => {
103
+ timer = setTimeout(() => reject(new Error(`Media redirect probe timed out (${timeoutMs}ms)`)), timeoutMs);
104
+ })]);
105
+ } finally {
106
+ if (timer !== undefined) clearTimeout(timer);
107
+ }
108
+ };
109
+
110
+ /**
111
+ * Whether these headers look like a media credential — the only thing the
112
+ * probe can fix, and the only thing worth a preflight.
113
+ *
114
+ * A header the `media` layer set is media-specific by construction. Otherwise
115
+ * only `Authorization` counts: an app authenticating one API for browse and
116
+ * media alike puts it on the shared `request` layer, and that is the common
117
+ * shape. Shared plumbing that also rides browse requests — an `X-Client-Version`
118
+ * — is not a credential and must not trigger the probe.
119
+ *
120
+ * The residual case is a shared layer carrying a custom auth header, say
121
+ * `X-Api-Key`, for media that needs it: move it to the `media` layer and the
122
+ * probe runs.
123
+ */
124
+ const isMediaAuth = (headers, mediaLayerHeaders) => mediaLayerHeaders || Object.keys(headers).some(key => key.toLowerCase() === 'authorization');
125
+ /**
126
+ * Origin of an http(s) URL, or undefined when it cannot be determined.
127
+ *
128
+ * Relative URLs are resolved against the document, because that is what the
129
+ * probe's own `fetch` does — a `src` of `/api/authed/7` is a request to the
130
+ * page's origin, so the credential went there. Without this a relative `src`
131
+ * has no origin, never matches the absolute URL the response reports, and the
132
+ * headers are dropped from every load that uses one.
133
+ *
134
+ * Parsed rather than pattern-matched so the comparison normalizes the way
135
+ * Android's `CrossOriginHeaderStripper.sameOrigin` does: `URL.host` omits a
136
+ * default port, so `https://a.com` and `https://a.com:443` are one origin, and
137
+ * it excludes userinfo, so `https://user@a.com` is not a third.
138
+ */
139
+ const originOf = url => {
140
+ try {
141
+ const base = typeof location === 'undefined' ? undefined : location.href;
142
+ const {
143
+ protocol,
144
+ host
145
+ } = new URL(url, base);
146
+ if (protocol !== 'http:' && protocol !== 'https:') return undefined;
147
+ return `${protocol}//${host}`;
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ };
152
+
153
+ /**
154
+ * Whether the redirect left the origin the credential was addressed to.
155
+ *
156
+ * Only when both origins are known and differ. An origin we could not work out
157
+ * — a relative URL with no document base, outside a browser — is not evidence
158
+ * of a hop, and treating it as one is how the headers went missing before.
159
+ */
160
+ const leftTheOrigin = (from, to) => {
161
+ const origin = originOf(from);
162
+ return origin !== undefined && originOf(to) !== origin;
163
+ };
164
+ async function followMediaRedirect(url, headers, {
165
+ fetchImpl,
166
+ mediaLayerHeaders = false,
167
+ timeoutMs = PROBE_TIMEOUT_MS
168
+ } = {}) {
169
+ if (!headers || Object.keys(headers).length === 0) return {
170
+ src: url,
171
+ headers
172
+ };
173
+ if (!isMediaAuth(headers, mediaLayerHeaders)) return {
174
+ src: url,
175
+ headers
176
+ };
177
+ if (isManifestUrl(url)) return {
178
+ src: url,
179
+ headers
180
+ };
181
+ if (isLocalUrl(url)) return {
182
+ src: url,
183
+ headers
184
+ };
185
+ const doFetch = fetchImpl ?? globalThis.fetch;
186
+ if (typeof doFetch !== 'function') return {
187
+ src: url,
188
+ headers
189
+ };
190
+ try {
191
+ // HEAD walks the chain without tripping download counting, which keys off
192
+ // ranged GETs. Deadlined, because the load waits on it.
193
+ const response = await fetchWithDeadline(doFetch, url, headers, timeoutMs);
194
+ const src = response.url || url;
195
+
196
+ // The status belongs to the *last* hop, so it only speaks for the
197
+ // authenticating endpoint when there was no redirect. Once one has
198
+ // happened, hop one authenticated and handed us its target, and a refusal
199
+ // past that is routinely about the method rather than the credential:
200
+ // presigned S3 and GCS URLs sign it — SigV4's canonical request opens with
201
+ // HTTPMethod, GCS V4's with HTTP_VERB — so a URL signed for GET answers a
202
+ // HEAD with 403. Keep the signed URL, which is the whole point of the walk,
203
+ // and let a real error surface through the player.
204
+ if (!response.ok && src === url) return {
205
+ src: url,
206
+ headers
207
+ };
208
+ return {
209
+ src,
210
+ headers: leftTheOrigin(url, src) ? undefined : headers
211
+ };
212
+ } catch {
213
+ return {
214
+ src: url,
215
+ headers
216
+ };
217
+ }
218
+ }
219
+ //# sourceMappingURL=followMediaRedirect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["isLocalUrl","url","test","MANIFEST_EXTENSIONS","Set","isManifestUrl","path","split","filename","slice","lastIndexOf","pieces","length","has","toLowerCase","PROBE_TIMEOUT_MS","timeoutSignal","ms","AbortSignal","undefined","timeout","AbortController","controller","setTimeout","abort","signal","fetchWithDeadline","doFetch","headers","timeoutMs","timer","Promise","race","method","redirect","_resolve","reject","Error","clearTimeout","isMediaAuth","mediaLayerHeaders","Object","keys","some","key","originOf","base","location","href","protocol","host","URL","leftTheOrigin","from","to","origin","followMediaRedirect","fetchImpl","src","globalThis","fetch","response","ok"],"sourceRoot":"../../../../src","sources":["web/http/followMediaRedirect.ts"],"mappings":"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","ignoreList":[]}
@@ -6,6 +6,7 @@ import { classifyTrackNavigation } from './browser/classifyTrackNavigation';
6
6
  import { FavoriteManager } from './browser/FavoriteManager';
7
7
  import { NavigationErrorManager } from './browser/NavigationErrorManager';
8
8
  import { SearchManager } from './browser/SearchManager';
9
+ import { followMediaRedirect } from './http/followMediaRedirect';
9
10
  import { HttpClient } from './http/HttpClient';
10
11
  import { RequestConfigBuilder } from './http/RequestConfigBuilder';
11
12
  import { NowPlayingManager } from './player/NowPlayingManager';
@@ -41,7 +42,6 @@ export class NativeAudioBrowser extends QueuePlayer {
41
42
  // Managers
42
43
 
43
44
  // Player state
44
- currentLoadId = 0;
45
45
  progressTimer = new PlaybackTimer();
46
46
  intervalTimer = new PlaybackTimer();
47
47
  _online = typeof navigator !== 'undefined' ? navigator.onLine : true;
@@ -208,6 +208,13 @@ export class NativeAudioBrowser extends QueuePlayer {
208
208
  this.onPlaybackError({
209
209
  error: newState.error
210
210
  });
211
+ } else if (oldState.state === 'error') {
212
+ // Leaving the error state clears it, as it does natively. The getter
213
+ // already reads undefined — consumers subscribe to the event, so without
214
+ // this a stale error outlives the next successful load.
215
+ this.onPlaybackError({
216
+ error: undefined
217
+ });
211
218
  }
212
219
  }
213
220
  /**
@@ -452,68 +459,98 @@ export class NativeAudioBrowser extends QueuePlayer {
452
459
  * Supports the transform callback for URL manipulation.
453
460
  * Mirrors Android's MediaFactory.getMediaRequestConfig behavior.
454
461
  */
455
- async resolveMediaUrl(src) {
462
+ async resolveMediaRequest(src, track) {
463
+ // The *resolved* request layer, not `configuration.request`: a
464
+ // resolver-only config would otherwise reach media with no baseUrl and no
465
+ // headers. Browse, search and browse artwork all read it this way too.
466
+ //
467
+ // Caught, because unlike those three this one cannot fail the load: a
468
+ // resolver hiccup — a token refresh timing out — would otherwise take down
469
+ // a track whose `src` is already absolute and public and needed no layer at
470
+ // all. Native degrades the same way, `getMediaRequestConfig` returning null
471
+ // and the original URL playing on.
472
+ let request;
473
+ try {
474
+ request = await this.browserManager.resolvedRequestConfig();
475
+ } catch (e) {
476
+ console.error('Failed to resolve the request layer for media', e);
477
+ }
456
478
  const {
457
- request,
458
479
  media
459
480
  } = this.browserManager.configuration;
460
- return RequestConfigBuilder.resolveMediaUrl(src, request, media);
481
+ return RequestConfigBuilder.resolveMediaRequest(src, request, media, track);
461
482
  }
462
483
 
463
484
  /**
464
- * Reload from the queue's own entry, whose `src` is still unresolved —
465
- * `current` holds the resolved track, and re-feeding it into `load()` would
466
- * re-resolve the resolved URL (double-applying a `transform`) and replace
467
- * the queue entry with it.
485
+ * The queue's own entry, so the queue stays the source of truth for what is
486
+ * playing. `current` preserves the track as queued — the resolved URL is
487
+ * passed to `load()` separately — so either is safe to re-feed.
468
488
  */
469
- reloadCurrent() {
489
+ trackToReload() {
470
490
  const index = this.queue.currentIndex;
471
- const sourceTrack = index !== undefined ? this.queue.getTrack(index) : undefined;
472
- if (sourceTrack) {
473
- this.load(sourceTrack);
474
- } else {
475
- super.reloadCurrent();
476
- }
491
+ const queued = index !== undefined ? this.queue.getTrack(index) : undefined;
492
+ // The queue entry wins, so it stays the source of truth for what is
493
+ // playing; `current` is the fallback for a player loaded without one.
494
+ return queued ?? super.trackToReload();
477
495
  }
478
- load(track, callback) {
479
- const element = this.requireElement();
480
-
481
- // Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
482
- this.nowPlayingManager.clearNowPlayingOverride();
483
496
 
484
- // Match Android: load() modifies the queue.
485
- // If queue is empty, add the track. If queue has items, replace at currentIndex.
497
+ /**
498
+ * Makes `track` the queue's current entry, emitting the change.
499
+ *
500
+ * Matches Android, where `load()` modifies the queue: an empty queue takes
501
+ * the track as its only entry, a populated one has its current entry
502
+ * replaced.
503
+ */
504
+ syncQueueTo(track) {
486
505
  if (this.queue.length === 0) {
487
506
  this.queue.setTracks([track]);
488
507
  this.queue.currentIndex = 0;
489
508
  this.emitQueueChanged();
490
- } else if (this.queue.currentIndex !== undefined && this.queue.getTrack(this.queue.currentIndex) !== track) {
509
+ return;
510
+ }
511
+ if (this.queue.currentIndex !== undefined && this.queue.getTrack(this.queue.currentIndex) !== track) {
491
512
  this.queue.replaceTrack(this.queue.currentIndex, track);
492
513
  this.emitQueueChanged();
493
514
  }
515
+ }
516
+
517
+ /**
518
+ * Everything native does in its media-item transition, all of it before any
519
+ * network work: announce the new track, publish its now-playing metadata,
520
+ * cut the outgoing one, and report `loading`.
521
+ *
522
+ * Android's `onMediaItemTransition` fires at `prepare()` and does exactly
523
+ * this, with its LOADING following from `onEvents`; resolution happens later
524
+ * still, inside `TransformingDataSource.open()`. Doing it here rather than
525
+ * after the URL resolves is what gives the resolution window a state, and
526
+ * keeps `activeTrack` ahead of `loading` — the order a consumer that reads
527
+ * `getActiveTrack()` on the loading edge depends on.
528
+ */
529
+ transitionTo(track) {
530
+ const element = this.requireElement();
494
531
  const lastTrack = this.current;
495
532
  const lastPosition = element.currentTime;
496
533
  const lastIndex = this.queue.lastIndex;
497
534
  const currentIndex = this.queue.currentIndex;
498
535
 
499
- // Set loading flag early so seekTo() calls during async URL resolution
500
- // are captured as pending seeks rather than silently dropped
501
- this._loadInProgress = true;
502
- this._pendingSeek = undefined;
536
+ // A re-prepare of the item already current is not a transition, and must
537
+ // not be announced as one: ExoPlayer's `prepare()` does not re-fire
538
+ // `onMediaItemTransition` for an unchanged media item, so a consumer that
539
+ // resets on a track change would have its UI wiped by every retry. The
540
+ // `lastTrack === track` payload was the tell.
541
+ const isTransition = this.current !== track;
503
542
 
504
- // Resolve the media URL before loading (async but we don't await)
505
- const loadId = ++this.currentLoadId;
506
- const doLoad = async () => {
507
- const resolvedTrack = track.src ? {
508
- ...track,
509
- src: await this.resolveMediaUrl(track.src)
510
- } : track;
511
-
512
- // A newer load() was called while resolving — discard this stale result
513
- if (loadId !== this.currentLoadId) {
514
- return;
515
- }
516
- super.load(resolvedTrack, loadedTrack => {
543
+ // `current` first, so `getActiveTrack()` already reads the new track inside
544
+ // a handler and so `getNowPlaying()` publishes the new track's metadata. It
545
+ // also stops `getActiveTrackIndex()` — which moved with the queue already,
546
+ // synchronously — from disagreeing with it for the length of the
547
+ // resolution.
548
+ this.current = track;
549
+
550
+ // Separately guarded: a throwing consumer handler must not skip the
551
+ // publish, and neither may fail the load.
552
+ if (isTransition) {
553
+ try {
517
554
  this.onPlaybackActiveTrackChanged({
518
555
  lastTrack,
519
556
  lastPosition,
@@ -521,24 +558,100 @@ export class NativeAudioBrowser extends QueuePlayer {
521
558
  index: currentIndex,
522
559
  track
523
560
  });
524
-
525
- // Update now playing metadata
561
+ } catch (error) {
562
+ console.error('Failed to announce the active track change:', error);
563
+ }
564
+ try {
565
+ // Once, here: duration reaches the media session through
566
+ // `updateProgress()` on each tick, so only metadata needs publishing.
567
+ // Android publishes from the same transition handler that announces.
526
568
  const nowPlaying = this.getNowPlaying();
527
569
  if (nowPlaying) {
528
570
  this.publishNowPlaying(nowPlaying);
529
571
  }
572
+ } catch (error) {
573
+ console.error('Failed to publish now-playing metadata:', error);
574
+ }
575
+ }
576
+
577
+ // Stop the outgoing track now rather than when Shaka eventually gets a URL.
578
+ // Announcing the new track while the old one is still audible is worse than
579
+ // the silence, and native has no such window — prepare() cuts the old item
580
+ // at the moment it announces. This adds no teardown: `player.load()`
581
+ // already unloads first (shaka player.js:1757-1762). It only moves when.
582
+ // Verified in a real browser to emit no `pause`, so no state comes of it.
583
+ this.requirePlayer().unload().catch(error => console.error('Failed to unload the previous track:', error));
584
+
585
+ // Loading covers the resolution too. Shaka dispatches its own `loading`
586
+ // once `player.load()` is called — after resolution — and the state setter
587
+ // drops that as a no-op. The `buffering` that follows is Shaka's to report.
588
+ this.dispatch({
589
+ type: 'trackLoading'
590
+ });
591
+ }
592
+ load(track, callback) {
593
+ // Before the queue is touched, so a load before `setupPlayer()` throws
594
+ // rather than half-applying.
595
+ this.requireElement();
530
596
 
597
+ // Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
598
+ this.nowPlayingManager.clearNowPlayingOverride();
599
+ this.syncQueueTo(track);
600
+
601
+ // Begun here, at the call, so a newer load supersedes this one immediately
602
+ // rather than once its own URL has resolved. Both continuations below carry
603
+ // it, as does `super.load()`.
604
+ const attempt = this.beginLoadAttempt();
605
+
606
+ // Loading ends being stopped. `dispatch()` drops every event while
607
+ // `_isStopped`, to keep Shaka's teardown noise off the stopped state, so
608
+ // the `trackLoading` in `transitionTo` would be swallowed on the stop → play
609
+ // path and the resolution window would report nothing. `super.load()` clears
610
+ // this too, but only after the URL resolves.
611
+ this._isStopped = false;
612
+
613
+ // Set loading flag early so seekTo() calls during async URL resolution
614
+ // are captured as pending seeks rather than silently dropped
615
+ this._loadInProgress = true;
616
+ this._pendingSeek = undefined;
617
+ this.transitionTo(track);
618
+
619
+ // Resolve the media URL before loading (async but we don't await)
620
+ const doLoad = async () => {
621
+ const resolvedRequest = track.src ? await this.resolveMediaRequest(track.src, track) : undefined;
622
+ // Web-only, and forced: a progressive file plays via `mediaElement.src`,
623
+ // which cannot carry headers. Native sends them with the media request
624
+ // and follows the redirect inline, so it needs none of this.
625
+ const resolvedMedia = resolvedRequest ? await followMediaRedirect(resolvedRequest.path ?? track.src, resolvedRequest.headers, {
626
+ mediaLayerHeaders: resolvedRequest.mediaLayerHeaders
627
+ }) : undefined;
628
+
629
+ // Something took over while the URL was resolving — discard this result
630
+ if (!attempt.isCurrent) return;
631
+ super.load(track, loadedTrack => {
531
632
  // Call the provided callback if any
532
633
  if (callback) {
533
634
  callback(loadedTrack);
534
635
  }
636
+ }, {
637
+ headers: resolvedMedia?.headers,
638
+ src: resolvedMedia?.src,
639
+ attempt
535
640
  });
536
641
  };
537
642
 
538
643
  // Execute async load without blocking, with error handling
539
644
  doLoad().catch(error => {
540
- // Only the active load may surface a resolution failure.
541
- if (loadId !== this.currentLoadId) return;
645
+ // A backstop with no reachable trigger today, kept because an unhandled
646
+ // rejection would be worse than a dead branch. Every await inside
647
+ // `doLoad` catches internally; `shaka.Player.load` is `async`, so it
648
+ // rejects rather than throwing and its rejection is handled against the
649
+ // attempt inside `Player.load`; and `this.player` is only ever assigned,
650
+ // so `requirePlayer()` cannot start succeeding and then fail mid-load.
651
+ //
652
+ // No attempt check either, for the same reason it would be dead: nothing
653
+ // awaits between the guard above and `super.load()`, so the attempt
654
+ // cannot have changed. Add an await there and this needs the check back.
542
655
  this._loadInProgress = false;
543
656
  this._pendingSeek = undefined;
544
657
  console.error('Error loading track:', error);
@@ -701,10 +814,8 @@ export class NativeAudioBrowser extends QueuePlayer {
701
814
  this.clearSleepTimerIfFading();
702
815
  }
703
816
  stop() {
704
- // Invalidate any in-flight load: its post-await continuation would call
705
- // super.load(), whose first line re-arms the player (_isStopped = false)
706
- // and revives the state out of Stopped.
707
- this.currentLoadId++;
817
+ // `Player.stop()` cancels the load attempt, so an in-flight load's
818
+ // continuation cannot re-arm the player and revive it out of Stopped.
708
819
  super.stop();
709
820
  this.clearSleepTimerIfFading();
710
821
  }
@@ -730,11 +841,13 @@ export class NativeAudioBrowser extends QueuePlayer {
730
841
  this.sleepTimer.clear();
731
842
  this.pause();
732
843
  this.onSleepTimerChanged(null);
733
- return;
844
+ // Nothing is loading, so the caller must still report the end — the
845
+ // element has stopped and its own `pause` is swallowed as end-of-track.
846
+ return false;
734
847
  }
735
848
 
736
849
  // Otherwise proceed with normal track end behavior
737
- super.onTrackEnded();
850
+ return super.onTrackEnded();
738
851
  }
739
852
 
740
853
  // MARK: Queue management