react-native-audio-browser 0.2.0-next.17 → 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/AudioBrowser.kt +160 -106
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
- 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/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
- package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
- package/android/src/main/res/values/strings.xml +6 -21
- package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
- package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
- package/lib/commonjs/features/browser.js +8 -0
- package/lib/commonjs/features/browser.js.map +1 -1
- 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 +30 -12
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +16 -19
- 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/features/browser.js +8 -0
- package/lib/module/features/browser.js.map +1 -1
- 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 +30 -12
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +16 -19
- 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/features/browser.d.ts +8 -0
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- 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/features/browser.ts +8 -0
- 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 +32 -15
- package/src/web/browser/SearchManager.test.ts +75 -0
- package/src/web/browser/SearchManager.ts +23 -26
- 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/browser/__tests__/search-navigation.test.ts +76 -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
|
@@ -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
|
+
})
|
|
@@ -345,6 +345,9 @@ export class BrowserManager {
|
|
|
345
345
|
* results (no contextual URLs), like the native `makeSearchResult`.
|
|
346
346
|
* Matches Android's BrowserManager.search() which bypasses resolve() entirely.
|
|
347
347
|
*
|
|
348
|
+
* A failed search propagates, so `navigate` turns it into a navigation error
|
|
349
|
+
* the way it does a failed browse resolve.
|
|
350
|
+
*
|
|
348
351
|
* @param searchPath The search path (format: /__search?q=query)
|
|
349
352
|
* @returns ResolvedTrack containing search results as one untitled section
|
|
350
353
|
*/
|
|
@@ -379,17 +382,9 @@ export class BrowserManager {
|
|
|
379
382
|
}
|
|
380
383
|
// Handle request config-based search via the shared layered fetch.
|
|
381
384
|
else if (searchRoute.searchConfig) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
{
|
|
386
|
-
q: query
|
|
387
|
-
}
|
|
388
|
-
)
|
|
389
|
-
} catch (error) {
|
|
390
|
-
console.error('Search failed:', error)
|
|
391
|
-
return undefined
|
|
392
|
-
}
|
|
385
|
+
searchResults = await this.fetchSearchResults(searchRoute.searchConfig, {
|
|
386
|
+
q: query
|
|
387
|
+
})
|
|
393
388
|
}
|
|
394
389
|
|
|
395
390
|
// Search callbacks should return fresh tracks without contextual paths (like Android)
|
|
@@ -408,10 +403,13 @@ export class BrowserManager {
|
|
|
408
403
|
private async transformArtworkForContent(
|
|
409
404
|
content: ResolvedTrack
|
|
410
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.
|
|
411
412
|
const artworkConfig = this._configuration.artwork
|
|
412
|
-
if (!artworkConfig) {
|
|
413
|
-
return content
|
|
414
|
-
}
|
|
415
413
|
// The shared request layer applies to artwork too — use the resolved layer
|
|
416
414
|
// (resolver result or static config) so a resolver-only config still works.
|
|
417
415
|
await this.ensureLayersResolved()
|
|
@@ -531,6 +529,24 @@ export class BrowserManager {
|
|
|
531
529
|
return undefined
|
|
532
530
|
}
|
|
533
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
|
+
|
|
534
550
|
/**
|
|
535
551
|
* Ensures the request/browse layer configs are resolved for the current
|
|
536
552
|
* generation. Any present resolver is invoked once per generation and the
|
|
@@ -728,8 +744,9 @@ export class BrowserManager {
|
|
|
728
744
|
|
|
729
745
|
// Transform artwork URLs on tabs via the async resolver so the shared
|
|
730
746
|
// request layer (incl. its transform) applies, matching content/search.
|
|
747
|
+
// Gated per track inside the resolver, as on Android — see
|
|
748
|
+
// `transformArtworkForContent`.
|
|
731
749
|
const artworkConfig = this._configuration.artwork
|
|
732
|
-
if (!artworkConfig) return tabs
|
|
733
750
|
// Resolved request layer (resolver or static), matching content/search.
|
|
734
751
|
await this.ensureLayersResolved()
|
|
735
752
|
const requestConfig = this._resolvedRequest
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type { NativeBrowserConfiguration } from '../../types/browser-native'
|
|
3
|
+
import type { HttpClient } from '../http/HttpClient'
|
|
4
|
+
import { BrowserManager } from './BrowserManager'
|
|
5
|
+
import { FavoriteManager } from './FavoriteManager'
|
|
6
|
+
import { NavigationErrorManager } from './NavigationErrorManager'
|
|
7
|
+
import { SearchManager } from './SearchManager'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds a SearchManager over a BrowserManager whose HTTP client answers every
|
|
11
|
+
* request with `respond` — a resolved body or a rejection.
|
|
12
|
+
*/
|
|
13
|
+
function makeSearchManager(
|
|
14
|
+
respond: () => Promise<unknown>,
|
|
15
|
+
route: NonNullable<NativeBrowserConfiguration['routes']>[number] = {
|
|
16
|
+
path: '__search__',
|
|
17
|
+
searchConfig: { baseUrl: 'https://api.example.com' }
|
|
18
|
+
}
|
|
19
|
+
): SearchManager {
|
|
20
|
+
const httpClient = {
|
|
21
|
+
executeRequest: vi.fn().mockImplementation(respond)
|
|
22
|
+
} as unknown as HttpClient
|
|
23
|
+
const browserManager = new BrowserManager(
|
|
24
|
+
httpClient,
|
|
25
|
+
new FavoriteManager(),
|
|
26
|
+
new NavigationErrorManager()
|
|
27
|
+
)
|
|
28
|
+
browserManager.configuration = { routes: [route] }
|
|
29
|
+
return new SearchManager(browserManager)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const httpError = (): Error => {
|
|
33
|
+
const error = new Error('HTTP 500: Internal Server Error') as Error & {
|
|
34
|
+
code: string
|
|
35
|
+
statusCode: number
|
|
36
|
+
}
|
|
37
|
+
error.code = 'http-error'
|
|
38
|
+
error.statusCode = 500
|
|
39
|
+
return error
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('SearchManager', () => {
|
|
43
|
+
it('rejects when the search request fails', async () => {
|
|
44
|
+
const manager = makeSearchManager(() => Promise.reject(httpError()))
|
|
45
|
+
await expect(
|
|
46
|
+
manager.search({ query: 'jazz', reference: 'unknown' })
|
|
47
|
+
).rejects.toThrow('HTTP 500: Internal Server Error')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('resolves [] when the search found nothing', async () => {
|
|
51
|
+
const manager = makeSearchManager(() => Promise.resolve({ children: [] }))
|
|
52
|
+
await expect(
|
|
53
|
+
manager.search({ query: 'jazz', reference: 'unknown' })
|
|
54
|
+
).resolves.toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('resolves the matching tracks', async () => {
|
|
58
|
+
const manager = makeSearchManager(() =>
|
|
59
|
+
Promise.resolve({ children: [{ title: 'Jazz FM', src: 'https://s/1' }] })
|
|
60
|
+
)
|
|
61
|
+
await expect(
|
|
62
|
+
manager.search({ query: 'jazz', reference: 'unknown' })
|
|
63
|
+
).resolves.toEqual([{ title: 'Jazz FM', src: 'https://s/1' }])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('propagates a rejecting search callback', async () => {
|
|
67
|
+
const manager = makeSearchManager(() => Promise.resolve([]), {
|
|
68
|
+
path: '__search__',
|
|
69
|
+
searchCallback: () => Promise.reject(new Error('callback exploded'))
|
|
70
|
+
})
|
|
71
|
+
await expect(
|
|
72
|
+
manager.search({ query: 'jazz', reference: 'unknown' })
|
|
73
|
+
).rejects.toThrow('callback exploded')
|
|
74
|
+
})
|
|
75
|
+
})
|