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,195 @@
1
+ /** @vitest-environment happy-dom */
2
+
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import type { Track } from '../../types'
5
+ import { LifecycleHarness, trackA, trackB } from './lifecycleHarness'
6
+
7
+ /**
8
+ * The edges the main lifecycle recordings miss, each one a bug the hoisted
9
+ * load path introduced or exposed. All were reproduced before being fixed;
10
+ * reverting any fix fails the test that names it.
11
+ */
12
+
13
+ let h: LifecycleHarness
14
+
15
+ beforeEach(async () => {
16
+ h = new LifecycleHarness()
17
+ await h.setup()
18
+ })
19
+
20
+ describe('a load superseded while still in flight', () => {
21
+ /**
22
+ * The eager `unload()` aborts the in-flight `player.load()`, and Shaka
23
+ * rejects an aborted load with LOAD_INTERRUPTED (7000). Unless the stale
24
+ * load is abandoned first, its own `catch` reports that as a playback
25
+ * error — an error banner on every fast track switch.
26
+ */
27
+ it('does not report the interrupted load as an error', async () => {
28
+ h.browser.setPlayWhenReady(true)
29
+ h.browser.load(trackA)
30
+ await h.flush() // player.load(A) is now pending
31
+ h.clear()
32
+
33
+ h.browser.load(trackB)
34
+ await h.flush()
35
+ await h.completeLoad()
36
+
37
+ expect(h.recorded).not.toContain('state:error')
38
+ expect(h.recorded.some((r) => r.startsWith('error:'))).toBe(false)
39
+ expect(h.browser.getPlayback().state).toBe('playing')
40
+ })
41
+ })
42
+
43
+ describe('a track end that does not advance the queue', () => {
44
+ /**
45
+ * Suppressing `ended` mid-queue is only safe when a load takes over the
46
+ * state. The sleep timer set to end-of-track pauses instead, and its
47
+ * `element.pause()` is a no-op on an already-ended element — so nothing
48
+ * moved the state and the player sat reporting `playing` with silence.
49
+ */
50
+ it('reports ended when the sleep timer stops instead of advancing', async () => {
51
+ h.browser.setQueue([trackA, trackB])
52
+ h.browser.setPlayWhenReady(true)
53
+ h.browser.skip(0)
54
+ await h.flush()
55
+ await h.completeLoad()
56
+ h.browser.setSleepTimerToEndOfTrack()
57
+ h.clear()
58
+
59
+ h.endTrack()
60
+ await h.flush()
61
+
62
+ expect(h.browser.getPlayback().state).toBe('ended')
63
+ })
64
+
65
+ it('still suppresses ended when the queue does advance', async () => {
66
+ h.browser.setQueue([trackA, trackB])
67
+ h.browser.setPlayWhenReady(true)
68
+ h.browser.skip(0)
69
+ await h.flush()
70
+ await h.completeLoad()
71
+ h.clear()
72
+
73
+ h.endTrack()
74
+ await h.flush()
75
+
76
+ expect(h.recorded).not.toContain('state:ended')
77
+ })
78
+ })
79
+
80
+ describe('loading after a stop', () => {
81
+ /**
82
+ * `dispatch()` drops every event while `_isStopped`, to keep Shaka's
83
+ * teardown noise off the stopped state. The `trackLoading` raised when
84
+ * `load()` is called was caught by that too, so the resolution window
85
+ * reported `stopped` rather than `loading` on the stop → play path.
86
+ */
87
+ it('reports loading while the url resolves', async () => {
88
+ h.browser.load(trackA)
89
+ await h.flush()
90
+ await h.completeLoad()
91
+ h.browser.stop()
92
+ await h.flush()
93
+
94
+ h.browser.play()
95
+
96
+ expect(h.browser.getPlayback().state).toBe('loading')
97
+ })
98
+ })
99
+
100
+ describe('the remembered resume position', () => {
101
+ /**
102
+ * `onError` also runs for a *load* failure, where the element was just torn
103
+ * down and reads 0. Recording that overwrote the real position, so a retry
104
+ * that failed cost the place a later one would have resumed from.
105
+ */
106
+ it('survives a retry that also fails', async () => {
107
+ h.browser.load(trackA)
108
+ await h.flush()
109
+ await h.completeLoad()
110
+
111
+ h.setPosition(42)
112
+ h.raiseError()
113
+ await h.flush()
114
+
115
+ h.failLoad()
116
+ h.browser.retry()
117
+ await h.flush()
118
+
119
+ h.succeedLoads()
120
+ h.browser.retry()
121
+ await h.flush()
122
+ await h.completeLoad()
123
+
124
+ expect(h.currentTime).toBe(42)
125
+ })
126
+
127
+ it('is not applied to a different track', async () => {
128
+ h.browser.load(trackA)
129
+ await h.flush()
130
+ await h.completeLoad()
131
+ h.setPosition(42)
132
+ h.raiseError()
133
+ await h.flush()
134
+
135
+ // a different track becomes current, then fails at the very start
136
+ h.browser.load(trackB)
137
+ await h.flush()
138
+ await h.completeLoad()
139
+ h.raiseError()
140
+ await h.flush()
141
+
142
+ h.browser.retry()
143
+ await h.flush()
144
+ await h.completeLoad()
145
+
146
+ expect(h.currentTime).toBe(0)
147
+ })
148
+ })
149
+
150
+ describe('re-preparing the current item', () => {
151
+ /**
152
+ * ExoPlayer's `prepare()` does not re-fire `onMediaItemTransition` for an
153
+ * unchanged item, so neither should a retry: a consumer that resets on a
154
+ * track change would have its UI wiped every time one ran. The
155
+ * `lastTrack === track` payload was the tell.
156
+ */
157
+ it('does not announce a transition on retry', async () => {
158
+ h.browser.load(trackA)
159
+ await h.flush()
160
+ await h.completeLoad()
161
+ h.raiseError()
162
+ await h.flush()
163
+ h.clear()
164
+
165
+ h.browser.retry()
166
+ await h.flush()
167
+
168
+ expect(h.recorded).not.toContain('activeTrack')
169
+ expect(h.recorded).not.toContain('nowPlaying')
170
+ })
171
+
172
+ it('still announces when the track actually changes', async () => {
173
+ h.browser.load(trackA)
174
+ await h.flush()
175
+ await h.completeLoad()
176
+ h.clear()
177
+
178
+ h.browser.load(trackB)
179
+ await h.flush()
180
+
181
+ expect(h.recorded).toContain('activeTrack')
182
+ })
183
+
184
+ it('announces the first load of all', async () => {
185
+ const fresh: Track = {
186
+ id: 'f',
187
+ title: 'F',
188
+ src: 'https://x.example.com/f.mp3'
189
+ }
190
+ h.browser.load(fresh)
191
+ await h.flush()
192
+
193
+ expect(h.recorded).toContain('activeTrack')
194
+ })
195
+ })
@@ -0,0 +1,175 @@
1
+ /** @vitest-environment happy-dom */
2
+
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import type { Track } from '../../types'
5
+ import {
6
+ LifecycleHarness,
7
+ deferredResolver,
8
+ trackA,
9
+ trackB
10
+ } from './lifecycleHarness'
11
+
12
+ /**
13
+ * What happens to a load that something else takes over from — a newer load,
14
+ * or a stop. Written before the two generation counters (`Player._loadGeneration`
15
+ * and `NativeAudioBrowser.currentLoadId`) were unified into one cancellation
16
+ * token, so they describe the behaviour rather than either mechanism.
17
+ */
18
+
19
+ let h: LifecycleHarness
20
+
21
+ beforeEach(async () => {
22
+ h = new LifecycleHarness()
23
+ await h.setup()
24
+ })
25
+
26
+ describe('a load superseded mid-resolution', () => {
27
+ it('never hands the stale url to the player', async () => {
28
+ const resolver = deferredResolver()
29
+ h.browser.configuration = resolver.configuration
30
+
31
+ h.browser.load(trackA)
32
+ await h.flush()
33
+ h.browser.load(trackB)
34
+ await h.flush()
35
+ expect(resolver.calls()).toBe(2)
36
+
37
+ // the winner resolves first, then the superseded one arrives late
38
+ resolver.release(2)
39
+ await h.flush()
40
+ resolver.release(1)
41
+ await h.flush()
42
+
43
+ expect(h.loadedUrls).toEqual(['https://cdn.example.com/2.mp3'])
44
+ })
45
+
46
+ it('leaves the winner playing', async () => {
47
+ const resolver = deferredResolver()
48
+ h.browser.configuration = resolver.configuration
49
+ h.browser.setPlayWhenReady(true)
50
+
51
+ h.browser.load(trackA)
52
+ await h.flush()
53
+ h.browser.load(trackB)
54
+ await h.flush()
55
+
56
+ resolver.release(2)
57
+ await h.flush()
58
+ await h.completeLoad()
59
+ resolver.release(1)
60
+ await h.flush()
61
+
62
+ expect(h.browser.getActiveTrack()?.id).toBe(trackB.id)
63
+ expect(h.browser.getPlayback().state).toBe('playing')
64
+ })
65
+ })
66
+
67
+ describe('a load superseded after its own url resolved', () => {
68
+ /**
69
+ * The gap the two generation counters left. Supersession was registered by
70
+ * the *inner* `super.load()`, which only runs once the URL has resolved — so
71
+ * a load whose Shaka promise had already settled ran its own success path
72
+ * even though a newer `load()` had been called first. One token begun at the
73
+ * call closes it.
74
+ */
75
+ it('does not run its callback once a newer load has begun', async () => {
76
+ const resolver = deferredResolver()
77
+ h.browser.configuration = resolver.configuration
78
+ h.browser.setPlayWhenReady(true)
79
+
80
+ const seen: Array<string | undefined> = []
81
+ h.browser.load(trackA, (track) => seen.push(track.id))
82
+ await h.flush()
83
+ resolver.release(1)
84
+ await h.flush() // super.load(A) has run; its Shaka load is pending
85
+
86
+ h.finishLoadOnly() // A's load settles, its .then is queued
87
+ h.browser.load(trackB) // superseded before that .then runs
88
+ await h.flush()
89
+
90
+ expect(seen).toEqual([])
91
+ })
92
+ })
93
+
94
+ describe('a load stopped mid-resolution', () => {
95
+ it('never hands its url to the player', async () => {
96
+ const resolver = deferredResolver()
97
+ h.browser.configuration = resolver.configuration
98
+
99
+ h.browser.load(trackA)
100
+ await h.flush()
101
+ h.browser.stop()
102
+ await h.flush()
103
+
104
+ resolver.release(1)
105
+ await h.flush()
106
+
107
+ expect(h.loadedUrls).toEqual([])
108
+ expect(h.browser.getPlayback().state).toBe('stopped')
109
+ })
110
+ })
111
+
112
+ describe('a load stopped while Shaka is loading', () => {
113
+ it('does not run the load callback, seek, or autoplay', async () => {
114
+ let loadedCalls = 0
115
+ h.browser.setPlayWhenReady(true)
116
+ h.browser.load(trackA, () => {
117
+ loadedCalls += 1
118
+ })
119
+ await h.flush() // player.load() is pending
120
+
121
+ h.browser.stop()
122
+ await h.flush()
123
+ await h.completeLoad()
124
+
125
+ expect(loadedCalls).toBe(0)
126
+ expect(h.browser.getPlayback().state).toBe('stopped')
127
+ })
128
+ })
129
+
130
+ describe('the load that wins', () => {
131
+ it('runs its callback', async () => {
132
+ const seen: Array<string | undefined> = []
133
+ h.browser.load(trackA, (track) => seen.push(track.id))
134
+ await h.flush()
135
+ await h.completeLoad()
136
+
137
+ expect(seen).toEqual([trackA.id])
138
+ })
139
+
140
+ it('applies a seek that arrived while it was loading', async () => {
141
+ const resolver = deferredResolver()
142
+ h.browser.configuration = resolver.configuration
143
+
144
+ h.browser.load(trackA)
145
+ await h.flush()
146
+ h.browser.seekTo(30) // captured as pending: the load is still in progress
147
+ resolver.release(1)
148
+ await h.flush()
149
+ await h.completeLoad()
150
+
151
+ expect(h.currentTime).toBe(30)
152
+ })
153
+
154
+ it('autoplays when the intent is set', async () => {
155
+ h.browser.setPlayWhenReady(true)
156
+ h.browser.load(trackA)
157
+ await h.flush()
158
+ await h.completeLoad()
159
+
160
+ expect(h.browser.getPlayback().state).toBe('playing')
161
+ })
162
+
163
+ it('does not autoplay without the intent', async () => {
164
+ const paused: Track = {
165
+ id: 'p',
166
+ title: 'P',
167
+ src: 'https://x.example.com/p.mp3'
168
+ }
169
+ h.browser.load(paused)
170
+ await h.flush()
171
+ await h.completeLoad()
172
+
173
+ expect(h.browser.getPlayback().state).toBe('ready')
174
+ })
175
+ })
@@ -0,0 +1,169 @@
1
+ /** @vitest-environment happy-dom */
2
+
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import type { Track } from '../../types'
5
+ import { LifecycleHarness } from './lifecycleHarness'
6
+
7
+ /**
8
+ * `retry()` after a failure, and `play()` from the error state — which on
9
+ * Android are the same operation.
10
+ *
11
+ * `retry()` is `player.prepare()` there; `play()` is `playWhenReady = true`
12
+ * followed by the same `prepare()`, which early-returns unless ExoPlayer is
13
+ * STATE_IDLE. iOS's `retry()` is `reloadResolving(startFromCurrentTime: true)`,
14
+ * with the reason in its own comment: "short-lived URLs/tokens may have expired
15
+ * since the failure".
16
+ *
17
+ * Web used to call `shaka.Player.retryStreaming()`, which returns false and
18
+ * does nothing unless the load mode is MEDIA_SOURCE — so for a progressive
19
+ * file, the case a signed URL exists for, `retry()` was a silent no-op.
20
+ *
21
+ * The point of these tests is the re-resolution: the resolver hands back a
22
+ * different URL each call, so a retry that replays the cached one is visible as
23
+ * a stale URL reaching the player.
24
+ */
25
+
26
+ let h: LifecycleHarness
27
+
28
+ /** Its `src` is the unsigned path; the resolver signs it freshly each call. */
29
+ const signed: Track = { id: 'signed', title: 'Signed', src: '/stream/7' }
30
+
31
+ /** A media resolver that mints a new URL every time it runs. */
32
+ function freshlySigning(): {
33
+ media: { resolve: () => Promise<{ path: string }> }
34
+ } {
35
+ let issued = 0
36
+ return {
37
+ media: {
38
+ resolve: () => {
39
+ issued += 1
40
+ return Promise.resolve({
41
+ path: `https://cdn.example.com/${issued}.mp3`
42
+ })
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ beforeEach(async () => {
49
+ h = new LifecycleHarness()
50
+ await h.setup()
51
+ })
52
+
53
+ describe('retry re-resolves', () => {
54
+ it('hands the player a freshly resolved url, not the one that failed', async () => {
55
+ h.browser.configuration = freshlySigning()
56
+
57
+ h.failLoad()
58
+ h.browser.load(signed)
59
+ await h.flush()
60
+ expect(h.loadedUrls).toEqual(['https://cdn.example.com/1.mp3'])
61
+ expect(h.browser.getPlayback().state).toBe('error')
62
+
63
+ h.succeedLoads()
64
+ h.browser.retry()
65
+ await h.flush()
66
+
67
+ // the retry resolved again rather than replaying the expired url
68
+ expect(h.loadedUrls).toEqual([
69
+ 'https://cdn.example.com/1.mp3',
70
+ 'https://cdn.example.com/2.mp3'
71
+ ])
72
+ })
73
+
74
+ it('play() from the error state re-resolves the same way', async () => {
75
+ h.browser.configuration = freshlySigning()
76
+
77
+ h.failLoad()
78
+ h.browser.load(signed)
79
+ await h.flush()
80
+
81
+ h.succeedLoads()
82
+ h.browser.play()
83
+ await h.flush()
84
+
85
+ expect(h.loadedUrls[1]).toBe('https://cdn.example.com/2.mp3')
86
+ // play() also sets the intent; retry() does not
87
+ expect(h.browser.getPlayWhenReady()).toBe(true)
88
+ })
89
+
90
+ it('retry() does not set the play intent', async () => {
91
+ h.failLoad()
92
+ h.browser.load({ id: 'a', title: 'A', src: 'https://x.example.com/a.mp3' })
93
+ await h.flush()
94
+
95
+ h.succeedLoads()
96
+ h.browser.retry()
97
+ await h.flush()
98
+
99
+ expect(h.browser.getPlayWhenReady()).toBe(false)
100
+ })
101
+
102
+ it('is a no-op on a healthy player, as ExoPlayer.prepare() is', async () => {
103
+ h.browser.load({ id: 'a', title: 'A', src: 'https://x.example.com/a.mp3' })
104
+ await h.flush()
105
+ await h.completeLoad()
106
+ const before = h.loadedUrls.length
107
+
108
+ h.browser.retry()
109
+ await h.flush()
110
+
111
+ expect(h.loadedUrls.length).toBe(before)
112
+ })
113
+
114
+ it('does nothing without a current track', async () => {
115
+ h.browser.retry()
116
+ await h.flush()
117
+
118
+ expect(h.loadedUrls).toEqual([])
119
+ })
120
+ })
121
+
122
+ describe('retry resumes position', () => {
123
+ it('resumes from where playback failed', async () => {
124
+ const track: Track = {
125
+ id: 'a',
126
+ title: 'A',
127
+ src: 'https://x.example.com/a.mp3'
128
+ }
129
+ h.browser.load(track)
130
+ await h.flush()
131
+ await h.completeLoad()
132
+
133
+ h.setPosition(42)
134
+ h.failLoad()
135
+ h.raiseError()
136
+ await h.flush()
137
+
138
+ h.succeedLoads()
139
+ h.browser.retry()
140
+ await h.flush()
141
+ await h.completeLoad()
142
+
143
+ expect(h.currentTime).toBe(42)
144
+ })
145
+
146
+ it('does not resume a live stream — a fresh connection is the live edge', async () => {
147
+ const live: Track = {
148
+ id: 'live',
149
+ title: 'Live',
150
+ src: 'https://x.example.com/live.mp3',
151
+ live: true
152
+ }
153
+ h.browser.load(live)
154
+ await h.flush()
155
+ await h.completeLoad()
156
+
157
+ h.setPosition(3600)
158
+ h.raiseError()
159
+ await h.flush()
160
+
161
+ h.browser.retry()
162
+ await h.flush()
163
+ await h.completeLoad()
164
+
165
+ // seeking to an hour into a connection that no longer exists asks for a
166
+ // point the new stream does not have
167
+ expect(h.currentTime).toBe(0)
168
+ })
169
+ })
@@ -403,10 +403,13 @@ export class BrowserManager {
403
403
  private async transformArtworkForContent(
404
404
  content: ResolvedTrack
405
405
  ): Promise<ResolvedTrack> {
406
+ // No short-circuit on a missing artwork config: Android gates per *track*
407
+ // (`artworkConfig == null && track.artwork == null`), so a track carrying a
408
+ // plain `artwork` URL still gets an `artworkSource`. Returning early here
409
+ // meant an app with no `artwork` block never populated the field at all —
410
+ // `resolveArtworkSourceAsync` already handles the no-config case the way
411
+ // `resolveArtworkUrl` does on Android.
406
412
  const artworkConfig = this._configuration.artwork
407
- if (!artworkConfig) {
408
- return content
409
- }
410
413
  // The shared request layer applies to artwork too — use the resolved layer
411
414
  // (resolver result or static config) so a resolver-only config still works.
412
415
  await this.ensureLayersResolved()
@@ -526,6 +529,24 @@ export class BrowserManager {
526
529
  return undefined
527
530
  }
528
531
 
532
+ /**
533
+ * Ensures the request layer is resolved for the current generation and
534
+ * returns it — the resolver's result when a `requestResolver` is configured,
535
+ * else the static `request`.
536
+ *
537
+ * Consumers outside the browse path (media URL building, artwork) must reach
538
+ * the request layer through this rather than reading `configuration.request`,
539
+ * or a resolver-only config silently loses its baseUrl, headers and transform
540
+ * exactly where a credential is needed. Mirrors Android's
541
+ * `BrowserManager.resolvedRequestConfig()`, which carries the same warning.
542
+ */
543
+ async resolvedRequestConfig(): Promise<
544
+ TransformableRequestConfig | undefined
545
+ > {
546
+ await this.ensureLayersResolved()
547
+ return this._resolvedRequest
548
+ }
549
+
529
550
  /**
530
551
  * Ensures the request/browse layer configs are resolved for the current
531
552
  * generation. Any present resolver is invoked once per generation and the
@@ -723,8 +744,9 @@ export class BrowserManager {
723
744
 
724
745
  // Transform artwork URLs on tabs via the async resolver so the shared
725
746
  // request layer (incl. its transform) applies, matching content/search.
747
+ // Gated per track inside the resolver, as on Android — see
748
+ // `transformArtworkForContent`.
726
749
  const artworkConfig = this._configuration.artwork
727
- if (!artworkConfig) return tabs
728
750
  // Resolved request layer (resolver or static), matching content/search.
729
751
  await this.ensureLayersResolved()
730
752
  const requestConfig = this._resolvedRequest
@@ -57,24 +57,24 @@ export class SearchManager {
57
57
  }
58
58
 
59
59
  // Transform artwork URLs on search results using async method with full Track access
60
+ // Gated per track inside the resolver, as on Android — a result carrying a
61
+ // plain `artwork` URL resolves even with no `artwork` config.
60
62
  const { request: requestConfig, artwork: artworkConfig } =
61
63
  this.browserManager.configuration
62
- if (artworkConfig) {
63
- results = await Promise.all(
64
- results.map(async (track) => {
65
- const artworkSource =
66
- await RequestConfigBuilder.resolveArtworkSourceAsync(
67
- track,
68
- requestConfig,
69
- artworkConfig
70
- )
71
- if (artworkSource && !track.artworkSource) {
72
- return { ...track, artworkSource }
73
- }
74
- return track
75
- })
76
- )
77
- }
64
+ results = await Promise.all(
65
+ results.map(async (track) => {
66
+ const artworkSource =
67
+ await RequestConfigBuilder.resolveArtworkSourceAsync(
68
+ track,
69
+ requestConfig,
70
+ artworkConfig
71
+ )
72
+ if (artworkSource && !track.artworkSource) {
73
+ return { ...track, artworkSource }
74
+ }
75
+ return track
76
+ })
77
+ )
78
78
 
79
79
  return results
80
80
  }