react-native-audio-browser 0.2.0-next.18 → 0.2.0-next.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +2 -1
- package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
- package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
- package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
- package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
- package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +24 -4
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +12 -12
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
- package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
- package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
- package/lib/module/web/NativeAudioBrowser.js +163 -50
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
- package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/module/web/TrackPlayer/Player.js +232 -39
- package/lib/module/web/TrackPlayer/Player.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +24 -4
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +12 -12
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/module/web/http/RequestConfigBuilder.js +110 -27
- package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/module/web/http/followMediaRedirect.js +215 -0
- package/lib/module/web/http/followMediaRedirect.js.map +1 -0
- package/lib/typescript/src/types/browser.d.ts +13 -4
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/types/browser.ts +13 -4
- package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
- package/src/web/NativeAudioBrowser.ts +193 -60
- package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
- package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
- package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
- package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
- package/src/web/TrackPlayer/Player.load.test.ts +137 -1
- package/src/web/TrackPlayer/Player.ts +256 -37
- package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
- package/src/web/__tests__/lifecycle.test.ts +345 -0
- package/src/web/__tests__/lifecycleHarness.ts +351 -0
- package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
- package/src/web/__tests__/loadSupersession.test.ts +175 -0
- package/src/web/__tests__/retry.test.ts +169 -0
- package/src/web/browser/BrowserManager.ts +26 -4
- package/src/web/browser/SearchManager.ts +16 -16
- package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
- package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
- package/src/web/http/RequestConfigBuilder.test.ts +241 -1
- package/src/web/http/RequestConfigBuilder.ts +140 -33
- package/src/web/http/followMediaRedirect.test.ts +289 -0
- package/src/web/http/followMediaRedirect.ts +253 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/** @vitest-environment happy-dom */
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { LifecycleHarness, trackA, trackB } from './lifecycleHarness'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the web player actually emits, recorded rather than reasoned about.
|
|
8
|
+
*
|
|
9
|
+
* Two halves, deliberately kept apart:
|
|
10
|
+
*
|
|
11
|
+
* - **Characterization** records today's behaviour. These assertions describe
|
|
12
|
+
* what happens, not what should — a change to any of them means the emission
|
|
13
|
+
* lifecycle moved, which is worth a deliberate look even when the move is an
|
|
14
|
+
* improvement.
|
|
15
|
+
* - **Parity** asserts what native emits for the same flow, derived from
|
|
16
|
+
* `PlaybackStateMachine.kt` and `PlayerListener.kt`. A divergence that is
|
|
17
|
+
* known but not yet closed belongs here as `it.fails`: the target is
|
|
18
|
+
* documented, the suite stays green while the gap stands, and the test
|
|
19
|
+
* starts failing the moment someone closes it — at which point flip it to a
|
|
20
|
+
* plain `it`.
|
|
21
|
+
*
|
|
22
|
+
* Fidelity: the harness runs the real wiring — `setupPlayer`, the real
|
|
23
|
+
* listeners, state machine and emitters — against a modelled Shaka whose
|
|
24
|
+
* behaviours are each cited to shaka-player 4.16.15's source. Every sequence
|
|
25
|
+
* below was also recorded in a real browser (Cypress against the Next.js
|
|
26
|
+
* example) and matched. Two modelled behaviours were *corrected* by that run:
|
|
27
|
+
* Shaka's teardown fires `emptied`/`loadstart` rather than `pause`, and a load
|
|
28
|
+
* that does not stall emits no Shaka `buffering` event at all.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
let h: LifecycleHarness
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
h = new LifecycleHarness()
|
|
35
|
+
await h.setup()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('characterization — what the player emits today', () => {
|
|
39
|
+
it('load announces the active track, then hands off to Shaka', async () => {
|
|
40
|
+
h.browser.load(trackA)
|
|
41
|
+
await h.flush()
|
|
42
|
+
|
|
43
|
+
expect(h.recorded).toEqual([
|
|
44
|
+
'queue',
|
|
45
|
+
'activeTrack',
|
|
46
|
+
'nowPlaying',
|
|
47
|
+
'state:loading',
|
|
48
|
+
'playing:false|buffering:false',
|
|
49
|
+
'state:buffering'
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('the rest of a first load, paused', async () => {
|
|
54
|
+
h.browser.load(trackA)
|
|
55
|
+
await h.flush()
|
|
56
|
+
h.clear()
|
|
57
|
+
await h.completeLoad()
|
|
58
|
+
|
|
59
|
+
expect(h.recorded).toEqual(['state:ready'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('the rest of a first load, playWhenReady', async () => {
|
|
63
|
+
h.browser.setPlayWhenReady(true)
|
|
64
|
+
h.browser.load(trackA)
|
|
65
|
+
await h.flush()
|
|
66
|
+
h.clear()
|
|
67
|
+
await h.completeLoad()
|
|
68
|
+
|
|
69
|
+
// no `ready`: suppressed while the intent is to play, as native does
|
|
70
|
+
expect(h.recorded).toEqual([
|
|
71
|
+
'state:playing',
|
|
72
|
+
'playing:true|buffering:false'
|
|
73
|
+
])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('switching tracks while playing', async () => {
|
|
77
|
+
h.browser.setPlayWhenReady(true)
|
|
78
|
+
h.browser.load(trackA)
|
|
79
|
+
await h.flush()
|
|
80
|
+
await h.completeLoad()
|
|
81
|
+
h.clear()
|
|
82
|
+
|
|
83
|
+
h.browser.load(trackB)
|
|
84
|
+
await h.flush()
|
|
85
|
+
await h.completeLoad()
|
|
86
|
+
|
|
87
|
+
// No `paused`: the teardown inside Shaka's load() fires `emptied` and
|
|
88
|
+
// `loadstart`, not `pause` — confirmed in a real browser.
|
|
89
|
+
expect(h.recorded).toEqual([
|
|
90
|
+
'queue',
|
|
91
|
+
'activeTrack',
|
|
92
|
+
'nowPlaying',
|
|
93
|
+
'state:loading',
|
|
94
|
+
'playing:true|buffering:true',
|
|
95
|
+
'state:buffering',
|
|
96
|
+
'state:playing',
|
|
97
|
+
'playing:true|buffering:false'
|
|
98
|
+
])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('pause then play', async () => {
|
|
102
|
+
h.browser.setPlayWhenReady(true)
|
|
103
|
+
h.browser.load(trackA)
|
|
104
|
+
await h.flush()
|
|
105
|
+
await h.completeLoad()
|
|
106
|
+
h.clear()
|
|
107
|
+
|
|
108
|
+
h.browser.pause()
|
|
109
|
+
await h.flush()
|
|
110
|
+
h.browser.play()
|
|
111
|
+
await h.flush()
|
|
112
|
+
|
|
113
|
+
expect(h.recorded).toEqual([
|
|
114
|
+
'playWhenReady:false',
|
|
115
|
+
'playing:false|buffering:false',
|
|
116
|
+
'state:paused',
|
|
117
|
+
'playWhenReady:true',
|
|
118
|
+
'playing:true|buffering:false',
|
|
119
|
+
'state:playing'
|
|
120
|
+
])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('mid-queue track end auto-advances', async () => {
|
|
124
|
+
h.browser.setQueue([trackA, trackB])
|
|
125
|
+
h.browser.setPlayWhenReady(true)
|
|
126
|
+
h.browser.skip(0)
|
|
127
|
+
await h.flush()
|
|
128
|
+
await h.completeLoad()
|
|
129
|
+
h.clear()
|
|
130
|
+
|
|
131
|
+
h.endTrack()
|
|
132
|
+
await h.flush()
|
|
133
|
+
|
|
134
|
+
// Neither `paused` nor `ended`: the element fires both when a track plays
|
|
135
|
+
// out, and both are suppressed so an advance looks like native's.
|
|
136
|
+
expect(h.recorded).toEqual([
|
|
137
|
+
'activeTrack',
|
|
138
|
+
'nowPlaying',
|
|
139
|
+
'state:loading',
|
|
140
|
+
'playing:true|buffering:true',
|
|
141
|
+
'state:buffering'
|
|
142
|
+
])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('last track end ends the queue', async () => {
|
|
146
|
+
h.browser.setQueue([trackA])
|
|
147
|
+
h.browser.setPlayWhenReady(true)
|
|
148
|
+
h.browser.skip(0)
|
|
149
|
+
await h.flush()
|
|
150
|
+
await h.completeLoad()
|
|
151
|
+
h.clear()
|
|
152
|
+
|
|
153
|
+
h.endTrack()
|
|
154
|
+
await h.flush()
|
|
155
|
+
|
|
156
|
+
// the real end does report `ended`; only the end-of-track `paused` is gone
|
|
157
|
+
expect(h.recorded).toEqual([
|
|
158
|
+
'playWhenReady:false',
|
|
159
|
+
'playing:false|buffering:false',
|
|
160
|
+
'state:ended',
|
|
161
|
+
'queueEnded'
|
|
162
|
+
])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('stop', async () => {
|
|
166
|
+
h.browser.setPlayWhenReady(true)
|
|
167
|
+
h.browser.load(trackA)
|
|
168
|
+
await h.flush()
|
|
169
|
+
await h.completeLoad()
|
|
170
|
+
h.clear()
|
|
171
|
+
|
|
172
|
+
h.browser.stop()
|
|
173
|
+
await h.flush()
|
|
174
|
+
|
|
175
|
+
expect(h.recorded).toEqual([
|
|
176
|
+
'playWhenReady:false',
|
|
177
|
+
'playing:false|buffering:false',
|
|
178
|
+
'state:stopped'
|
|
179
|
+
])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('a load Shaka rejects', async () => {
|
|
183
|
+
h.failLoad()
|
|
184
|
+
h.browser.load(trackA)
|
|
185
|
+
await h.flush()
|
|
186
|
+
|
|
187
|
+
// the code is whatever the rejection carried; -1 is the normalizer's
|
|
188
|
+
// fallback for a plain Error
|
|
189
|
+
expect(h.recorded).toEqual([
|
|
190
|
+
'queue',
|
|
191
|
+
'activeTrack',
|
|
192
|
+
'nowPlaying',
|
|
193
|
+
'state:loading',
|
|
194
|
+
'playing:false|buffering:false',
|
|
195
|
+
'state:buffering',
|
|
196
|
+
'state:error',
|
|
197
|
+
'error:-1'
|
|
198
|
+
])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('a track with no src', async () => {
|
|
202
|
+
h.browser.load({ id: 'x', title: 'No src' })
|
|
203
|
+
await h.flush()
|
|
204
|
+
|
|
205
|
+
expect(h.recorded).toEqual([
|
|
206
|
+
'queue',
|
|
207
|
+
'activeTrack',
|
|
208
|
+
'nowPlaying',
|
|
209
|
+
'state:loading',
|
|
210
|
+
'playing:false|buffering:false',
|
|
211
|
+
'state:error',
|
|
212
|
+
'error:invalid_track'
|
|
213
|
+
])
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('parity — what native emits for the same flow', () => {
|
|
218
|
+
/**
|
|
219
|
+
* Android fires the active-track callback from `onMediaItemTransition`
|
|
220
|
+
* (PlayerListener.kt:106) and maps the same event to LOADING in `onEvents`
|
|
221
|
+
* (:216-224), which ExoPlayer runs after the individual callbacks.
|
|
222
|
+
*/
|
|
223
|
+
it('announces the active track before reporting loading', async () => {
|
|
224
|
+
h.browser.load(trackA)
|
|
225
|
+
await h.flush()
|
|
226
|
+
|
|
227
|
+
const active = h.recorded.indexOf('activeTrack')
|
|
228
|
+
const loading = h.recorded.indexOf('state:loading')
|
|
229
|
+
expect(active).toBeGreaterThanOrEqual(0)
|
|
230
|
+
expect(loading).toBeGreaterThan(active)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Android always passes through STATE_BUFFERING after prepare. Shaka only
|
|
235
|
+
* fires its own `buffering` event when a load actually stalls, so a load
|
|
236
|
+
* served from cache used to skip the state entirely. `Player`'s `loading`
|
|
237
|
+
* listener now dispatches `waiting` alongside `trackLoading` so every load
|
|
238
|
+
* passes through it, as native's does.
|
|
239
|
+
*/
|
|
240
|
+
it('reports buffering between loading and playing', async () => {
|
|
241
|
+
h.browser.setPlayWhenReady(true)
|
|
242
|
+
h.browser.load(trackA)
|
|
243
|
+
await h.flush()
|
|
244
|
+
await h.completeLoad()
|
|
245
|
+
|
|
246
|
+
expect(h.recorded).toContain('state:buffering')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Android suppresses READY while playWhenReady — "READY is a transient
|
|
251
|
+
* before PLAYING — suppress it so consumers don't flash a settled/non-loading
|
|
252
|
+
* state mid-startup" (PlaybackStateMachine.kt, STATE_READY branch). The web
|
|
253
|
+
* machine now takes `playWhenReady` on the two edges that produce `ready` and
|
|
254
|
+
* suppresses it the same way.
|
|
255
|
+
*
|
|
256
|
+
* The web-only hazard that comes with it: a browser can refuse `play()`.
|
|
257
|
+
* `Player.onPlayRejected` clears the intent on anything but an `AbortError`,
|
|
258
|
+
* so a blocked autoplay settles instead of stranding on `buffering`.
|
|
259
|
+
*/
|
|
260
|
+
it('does not report ready mid-startup while playWhenReady', async () => {
|
|
261
|
+
h.browser.setPlayWhenReady(true)
|
|
262
|
+
h.browser.load(trackA)
|
|
263
|
+
await h.flush()
|
|
264
|
+
h.clear()
|
|
265
|
+
await h.completeLoad()
|
|
266
|
+
|
|
267
|
+
expect(h.recorded).not.toContain('state:ready')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Android never reports ENDED between tracks: STATE_ENDED is only reached at
|
|
272
|
+
* the end of the playlist, and a mid-playlist advance stays READY/BUFFERING.
|
|
273
|
+
* Web loads one track at a time, so the element's own `ended` used to surface
|
|
274
|
+
* and drop `PlayingState.playing` to false between every pair of tracks.
|
|
275
|
+
* `QueuePlayer.applyState` now advances without emitting it; the true queue
|
|
276
|
+
* end still reports `ended`.
|
|
277
|
+
*/
|
|
278
|
+
it('does not report ended when the queue advances', async () => {
|
|
279
|
+
h.browser.setQueue([trackA, trackB])
|
|
280
|
+
h.browser.setPlayWhenReady(true)
|
|
281
|
+
h.browser.skip(0)
|
|
282
|
+
await h.flush()
|
|
283
|
+
await h.completeLoad()
|
|
284
|
+
h.clear()
|
|
285
|
+
|
|
286
|
+
h.endTrack()
|
|
287
|
+
await h.flush()
|
|
288
|
+
|
|
289
|
+
expect(h.recorded).not.toContain('state:ended')
|
|
290
|
+
expect(h.recorded).not.toContain('playing:false|buffering:false')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Android's transition cuts the outgoing item and reports LOADING, with no
|
|
295
|
+
* PAUSED in between — and web matches. The teardown inside Shaka's load()
|
|
296
|
+
* fires `emptied` and `loadstart`, never `pause`, even mid-playback.
|
|
297
|
+
* Confirmed in a real browser; reading the HTML load algorithm had suggested
|
|
298
|
+
* otherwise.
|
|
299
|
+
*/
|
|
300
|
+
it('does not report paused when switching tracks', async () => {
|
|
301
|
+
h.browser.setPlayWhenReady(true)
|
|
302
|
+
h.browser.load(trackA)
|
|
303
|
+
await h.flush()
|
|
304
|
+
await h.completeLoad()
|
|
305
|
+
h.clear()
|
|
306
|
+
|
|
307
|
+
h.browser.load(trackB)
|
|
308
|
+
await h.flush()
|
|
309
|
+
|
|
310
|
+
expect(h.recorded).not.toContain('state:paused')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A track playing out fires `pause` before `ended` on the element, so web
|
|
315
|
+
* used to report `paused` at the end of every track where native reports
|
|
316
|
+
* nothing. The pause listener now ignores a pause raised once `ended` is
|
|
317
|
+
* already true.
|
|
318
|
+
*/
|
|
319
|
+
it('does not report paused when a track plays out', async () => {
|
|
320
|
+
h.browser.setQueue([trackA, trackB])
|
|
321
|
+
h.browser.setPlayWhenReady(true)
|
|
322
|
+
h.browser.skip(0)
|
|
323
|
+
await h.flush()
|
|
324
|
+
await h.completeLoad()
|
|
325
|
+
h.clear()
|
|
326
|
+
|
|
327
|
+
h.endTrack()
|
|
328
|
+
await h.flush()
|
|
329
|
+
|
|
330
|
+
expect(h.recorded).not.toContain('state:paused')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Android reports LOADING off the media-item transition, before any network
|
|
335
|
+
* work, so the URL-resolution window has a state. Web now does the same: the
|
|
336
|
+
* announcement and `trackLoading` both happen when `load()` is called, so the
|
|
337
|
+
* pair keeps native's order and the window is no longer silent. Shaka's own
|
|
338
|
+
* `loading` arrives later and the state setter drops it as a no-op.
|
|
339
|
+
*/
|
|
340
|
+
it('reports loading while the url is still resolving', async () => {
|
|
341
|
+
h.browser.load(trackA)
|
|
342
|
+
|
|
343
|
+
expect(h.browser.getPlayback().state).toBe('loading')
|
|
344
|
+
})
|
|
345
|
+
})
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import type { Track } from '../../types'
|
|
3
|
+
import { NativeAudioBrowser } from '../NativeAudioBrowser'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A recording harness for the web player's event lifecycle.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the other web tests, this does **not** stub `this.player` /
|
|
9
|
+
* `this.element` — it runs the real `setupPlayer()`, so the real listeners,
|
|
10
|
+
* state machine and emitters are all wired up. Only the two *sources* are
|
|
11
|
+
* modelled: Shaka, via the mock installed below, and the media element's own
|
|
12
|
+
* events, which the harness fires explicitly.
|
|
13
|
+
*
|
|
14
|
+
* The model is not a browser. Every behaviour it reproduces is one verified in
|
|
15
|
+
* `shaka-player` 4.16.15's source, cited at the point it is modelled, so the
|
|
16
|
+
* recordings are only as true as those citations. A real-browser run is still
|
|
17
|
+
* the final word — see `lifecycle.test.ts` for which recordings are waiting on
|
|
18
|
+
* one.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Events the harness records, in emission order. */
|
|
22
|
+
export type Recorded = string
|
|
23
|
+
|
|
24
|
+
export class FakeShakaPlayer {
|
|
25
|
+
private listeners = new Map<string, ((event: unknown) => void)[]>()
|
|
26
|
+
private assetUri: string | undefined
|
|
27
|
+
/** Set by the harness so `unload()` can reproduce the element teardown. */
|
|
28
|
+
onTeardown: (() => void) | undefined
|
|
29
|
+
private pendingLoad: (() => void) | undefined
|
|
30
|
+
private rejectLoad: ((reason: unknown) => void) | undefined
|
|
31
|
+
private failNextLoad = false
|
|
32
|
+
/** Every URL `load()` was called with, in order. */
|
|
33
|
+
readonly loadedUrls: string[] = []
|
|
34
|
+
|
|
35
|
+
static isBrowserSupported(): boolean {
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
attach(): Promise<void> {
|
|
40
|
+
return Promise.resolve()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getNetworkingEngine(): { registerRequestFilter: () => void } {
|
|
44
|
+
return { registerRequestFilter: () => {} }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
addEventListener(type: string, cb: (event: unknown) => void): void {
|
|
48
|
+
const existing = this.listeners.get(type) ?? []
|
|
49
|
+
existing.push(cb)
|
|
50
|
+
this.listeners.set(type, existing)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
emit(type: string, detail?: unknown): void {
|
|
54
|
+
for (const cb of this.listeners.get(type) ?? []) cb({ type, detail })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mirrors `shaka.Player.load` (player.js:1717+) in the two respects that
|
|
59
|
+
* matter to our listeners:
|
|
60
|
+
*
|
|
61
|
+
* 1. it unloads the previous asset first when one is loaded —
|
|
62
|
+
* `if (this.assetUri_) { await this.unload(false) }` at :1757-1762;
|
|
63
|
+
* 2. it dispatches `loading` *after* that, at :1807, behind several awaits —
|
|
64
|
+
* which is why the event never arrives synchronously.
|
|
65
|
+
*/
|
|
66
|
+
async load(uri: string): Promise<void> {
|
|
67
|
+
this.loadedUrls.push(uri)
|
|
68
|
+
if (this.assetUri !== undefined) await this.unload()
|
|
69
|
+
this.assetUri = uri
|
|
70
|
+
this.emit('loading')
|
|
71
|
+
if (this.failNextLoad) throw new Error('401')
|
|
72
|
+
// Shaka's load() promise settles once the media is ready, i.e. after the
|
|
73
|
+
// buffering/loaded events — `finishLoad()` drives that.
|
|
74
|
+
await new Promise<void>((resolve, reject) => {
|
|
75
|
+
this.pendingLoad = resolve
|
|
76
|
+
this.rejectLoad = reject
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
finishLoad(): void {
|
|
81
|
+
const resolve = this.pendingLoad
|
|
82
|
+
this.pendingLoad = undefined
|
|
83
|
+
this.rejectLoad = undefined
|
|
84
|
+
resolve?.()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
failLoads(): void {
|
|
88
|
+
this.failNextLoad = true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
succeedLoads(): void {
|
|
92
|
+
this.failNextLoad = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Raise a Shaka error, as a mid-playback failure would. */
|
|
96
|
+
raiseError(): void {
|
|
97
|
+
this.emit('error', { code: 3016, message: 'playback failed' })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Mirrors `unload()` (player.js:1445+). For `src=` content it clears the
|
|
102
|
+
* element and re-runs its load algorithm (:1603-1610 via
|
|
103
|
+
* `Dom.clearSourceFromVideo`).
|
|
104
|
+
*
|
|
105
|
+
* Verified in a real browser: that teardown fires `emptied` and `loadstart`
|
|
106
|
+
* — **not** `pause`, even with the element mid-playback. Reading the HTML
|
|
107
|
+
* load algorithm suggested otherwise; the browser disagreed. Nothing here
|
|
108
|
+
* dispatches an element event as a result.
|
|
109
|
+
*/
|
|
110
|
+
async unload(): Promise<void> {
|
|
111
|
+
// Real Shaka aborts an in-flight load() when unload() is called, rejecting
|
|
112
|
+
// it with LOAD_INTERRUPTED (7000). Modelled because a caller that unloads
|
|
113
|
+
// eagerly must invalidate that load first, or its rejection surfaces as a
|
|
114
|
+
// playback error.
|
|
115
|
+
const reject = this.rejectLoad
|
|
116
|
+
this.rejectLoad = undefined
|
|
117
|
+
this.pendingLoad = undefined
|
|
118
|
+
reject?.({ code: 7000, message: 'LOAD_INTERRUPTED' })
|
|
119
|
+
|
|
120
|
+
this.assetUri = undefined
|
|
121
|
+
this.onTeardown?.()
|
|
122
|
+
return Promise.resolve()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
retryStreaming(): void {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
vi.mock('shaka-player/dist/shaka-player.compiled', () => ({
|
|
129
|
+
default: {
|
|
130
|
+
polyfill: { installAll: () => {} },
|
|
131
|
+
Player: FakeShakaPlayer
|
|
132
|
+
}
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
export class LifecycleHarness {
|
|
136
|
+
readonly browser = new NativeAudioBrowser()
|
|
137
|
+
readonly recorded: Recorded[] = []
|
|
138
|
+
private element!: HTMLMediaElement
|
|
139
|
+
private shaka!: FakeShakaPlayer
|
|
140
|
+
private elementPlaying = false
|
|
141
|
+
|
|
142
|
+
async setup(): Promise<void> {
|
|
143
|
+
// `setupPlayer` attaches the Shaka instance to `window` under __DEV__,
|
|
144
|
+
// which the bundler defines and the test environment does not.
|
|
145
|
+
;(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false
|
|
146
|
+
|
|
147
|
+
await this.browser.setupPlayer({})
|
|
148
|
+
|
|
149
|
+
const browser = this.browser as unknown as {
|
|
150
|
+
element: HTMLMediaElement
|
|
151
|
+
player: FakeShakaPlayer
|
|
152
|
+
}
|
|
153
|
+
this.element = browser.element
|
|
154
|
+
this.shaka = browser.player
|
|
155
|
+
|
|
156
|
+
// happy-dom's media element has no TimeRanges; `getProgress()` reads it.
|
|
157
|
+
Object.defineProperty(this.element, 'buffered', {
|
|
158
|
+
value: { length: 0, end: () => 0 },
|
|
159
|
+
configurable: true
|
|
160
|
+
})
|
|
161
|
+
Object.defineProperty(this.element, 'duration', {
|
|
162
|
+
value: 100,
|
|
163
|
+
configurable: true
|
|
164
|
+
})
|
|
165
|
+
Object.defineProperty(this.element, 'ended', {
|
|
166
|
+
value: false,
|
|
167
|
+
configurable: true
|
|
168
|
+
})
|
|
169
|
+
Object.defineProperty(this.element, 'currentTime', {
|
|
170
|
+
value: 0,
|
|
171
|
+
writable: true,
|
|
172
|
+
configurable: true
|
|
173
|
+
})
|
|
174
|
+
Object.defineProperty(this.element, 'seekable', {
|
|
175
|
+
value: { length: 0, end: () => 0 },
|
|
176
|
+
configurable: true
|
|
177
|
+
})
|
|
178
|
+
// happy-dom's play()/pause() do not fire media events, so the harness
|
|
179
|
+
// fires them where a browser would.
|
|
180
|
+
this.element.play = () => {
|
|
181
|
+
this.startedPlaying()
|
|
182
|
+
return Promise.resolve()
|
|
183
|
+
}
|
|
184
|
+
this.element.pause = () => {
|
|
185
|
+
this.pausedByCommand()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// No element event on teardown — see FakeShakaPlayer.unload(). It does
|
|
189
|
+
// reset the position though: clearing `src` and re-running the element's
|
|
190
|
+
// load algorithm puts `currentTime` back to 0, which is why a resume has
|
|
191
|
+
// to be remembered before the teardown rather than read after it.
|
|
192
|
+
this.shaka.onTeardown = () => {
|
|
193
|
+
this.elementPlaying = false
|
|
194
|
+
this.setPosition(0)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.record()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private record(): void {
|
|
201
|
+
const push = (label: string) => this.recorded.push(label)
|
|
202
|
+
this.browser.onPlaybackChanged = (p) => push(`state:${p.state}`)
|
|
203
|
+
this.browser.onPlaybackActiveTrackChanged = () => push('activeTrack')
|
|
204
|
+
this.browser.onNowPlayingChanged = () => push('nowPlaying')
|
|
205
|
+
this.browser.onPlaybackQueueChanged = () => push('queue')
|
|
206
|
+
this.browser.onPlaybackQueueEnded = () => push('queueEnded')
|
|
207
|
+
this.browser.onPlaybackError = (e) =>
|
|
208
|
+
push(e.error ? `error:${e.error.code}` : 'errorCleared')
|
|
209
|
+
this.browser.onPlaybackPlayingState = (s) =>
|
|
210
|
+
push(`playing:${s.playing}|buffering:${s.buffering}`)
|
|
211
|
+
this.browser.onPlaybackPlayWhenReadyChanged = (e) =>
|
|
212
|
+
push(`playWhenReady:${e.playWhenReady}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
clear(): void {
|
|
216
|
+
this.recorded.length = 0
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private fireElement(type: string): void {
|
|
220
|
+
this.element.dispatchEvent(new Event(type))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private startedPlaying(): void {
|
|
224
|
+
this.elementPlaying = true
|
|
225
|
+
this.fireElement('playing')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private pausedByCommand(): void {
|
|
229
|
+
if (!this.elementPlaying) return
|
|
230
|
+
this.elementPlaying = false
|
|
231
|
+
this.fireElement('pause')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The rest of a successful Shaka load: `loaded`, then the `load()` promise
|
|
236
|
+
* settles, which is what lets the library's own `playWhenReady` handling
|
|
237
|
+
* start playback.
|
|
238
|
+
*
|
|
239
|
+
* Verified in a real browser: a load that does not stall emits **no**
|
|
240
|
+
* `buffering` event, so the PlaybackState goes `loading → ready → playing`
|
|
241
|
+
* with no `buffering` in it. Use {@link rebuffer} for the stalling case.
|
|
242
|
+
*/
|
|
243
|
+
async completeLoad(): Promise<void> {
|
|
244
|
+
this.shaka.emit('loaded')
|
|
245
|
+
this.shaka.finishLoad()
|
|
246
|
+
await this.flush()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A mid-playback stall, which is where Shaka's `buffering` event does fire. */
|
|
250
|
+
async rebuffer(): Promise<void> {
|
|
251
|
+
this.shaka.emit('buffering', { buffering: true })
|
|
252
|
+
await this.flush()
|
|
253
|
+
this.shaka.emit('buffering', { buffering: false })
|
|
254
|
+
await this.flush()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Shaka rejecting the load, as a 401 would. */
|
|
258
|
+
failLoad(): void {
|
|
259
|
+
this.shaka.failLoads()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Stop rejecting loads, so a retry can succeed. */
|
|
263
|
+
succeedLoads(): void {
|
|
264
|
+
this.shaka.succeedLoads()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Resolves Shaka's pending load() without emitting its events. */
|
|
268
|
+
finishLoadOnly(): void {
|
|
269
|
+
this.shaka.finishLoad()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A mid-playback failure, rather than one raised by the load itself. */
|
|
273
|
+
raiseError(): void {
|
|
274
|
+
this.shaka.raiseError()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Every URL handed to Shaka's `load()`, in order. */
|
|
278
|
+
get loadedUrls(): string[] {
|
|
279
|
+
return this.shaka.loadedUrls
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
setPosition(seconds: number): void {
|
|
283
|
+
Object.defineProperty(this.element, 'currentTime', {
|
|
284
|
+
value: seconds,
|
|
285
|
+
writable: true,
|
|
286
|
+
configurable: true
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
get currentTime(): number {
|
|
291
|
+
return this.element.currentTime
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A track playing out. Verified in a real browser: the element fires `pause`
|
|
296
|
+
* and then `ended`. `ended` is already true on the element by then — it is
|
|
297
|
+
* the ended-playback condition that triggers both — which is what the pause
|
|
298
|
+
* listener keys off to tell this apart from a real pause.
|
|
299
|
+
*/
|
|
300
|
+
endTrack(): void {
|
|
301
|
+
this.elementPlaying = false
|
|
302
|
+
Object.defineProperty(this.element, 'ended', {
|
|
303
|
+
value: true,
|
|
304
|
+
configurable: true
|
|
305
|
+
})
|
|
306
|
+
this.fireElement('pause')
|
|
307
|
+
this.fireElement('ended')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
flush(): Promise<void> {
|
|
311
|
+
return new Promise((resolve) => setTimeout(resolve, 0))
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A `media.resolve` whose every call hangs until released, so two loads can be
|
|
317
|
+
* interleaved deliberately — the only way to exercise what happens to a load
|
|
318
|
+
* that a newer one supersedes mid-resolution.
|
|
319
|
+
*/
|
|
320
|
+
export function deferredResolver(): {
|
|
321
|
+
configuration: { media: { resolve: () => Promise<{ path: string }> } }
|
|
322
|
+
/** Releases the nth call (1-based) with its own distinct URL. */
|
|
323
|
+
release: (n: number) => void
|
|
324
|
+
calls: () => number
|
|
325
|
+
} {
|
|
326
|
+
const pending: Array<(value: { path: string }) => void> = []
|
|
327
|
+
return {
|
|
328
|
+
configuration: {
|
|
329
|
+
media: {
|
|
330
|
+
resolve: () =>
|
|
331
|
+
new Promise<{ path: string }>((resolve) => {
|
|
332
|
+
pending.push(resolve)
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
release: (n) =>
|
|
337
|
+
pending[n - 1]?.({ path: `https://cdn.example.com/${n}.mp3` }),
|
|
338
|
+
calls: () => pending.length
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export const trackA: Track = {
|
|
343
|
+
id: 'a',
|
|
344
|
+
title: 'Track A',
|
|
345
|
+
src: 'https://media.example.com/a.mp3'
|
|
346
|
+
}
|
|
347
|
+
export const trackB: Track = {
|
|
348
|
+
id: 'b',
|
|
349
|
+
title: 'Track B',
|
|
350
|
+
src: 'https://media.example.com/b.mp3'
|
|
351
|
+
}
|