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,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
+ }