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.
Files changed (81) hide show
  1. package/android/build.gradle +2 -1
  2. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
  3. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
  4. package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
  5. package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
  6. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
  7. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
  8. package/android/src/main/res/values/strings.xml +6 -21
  9. package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
  10. package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
  11. package/lib/commonjs/features/browser.js +8 -0
  12. package/lib/commonjs/features/browser.js.map +1 -1
  13. package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
  14. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  15. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
  16. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
  17. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  18. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  19. package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
  20. package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
  21. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
  22. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
  23. package/lib/commonjs/web/browser/BrowserManager.js +30 -12
  24. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  25. package/lib/commonjs/web/browser/SearchManager.js +16 -19
  26. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  27. package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
  28. package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
  29. package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
  30. package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
  31. package/lib/module/features/browser.js +8 -0
  32. package/lib/module/features/browser.js.map +1 -1
  33. package/lib/module/web/NativeAudioBrowser.js +163 -50
  34. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  35. package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
  36. package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
  37. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  38. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  39. package/lib/module/web/TrackPlayer/Player.js +232 -39
  40. package/lib/module/web/TrackPlayer/Player.js.map +1 -1
  41. package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
  42. package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
  43. package/lib/module/web/browser/BrowserManager.js +30 -12
  44. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  45. package/lib/module/web/browser/SearchManager.js +16 -19
  46. package/lib/module/web/browser/SearchManager.js.map +1 -1
  47. package/lib/module/web/http/RequestConfigBuilder.js +110 -27
  48. package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
  49. package/lib/module/web/http/followMediaRedirect.js +215 -0
  50. package/lib/module/web/http/followMediaRedirect.js.map +1 -0
  51. package/lib/typescript/src/features/browser.d.ts +8 -0
  52. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  53. package/lib/typescript/src/types/browser.d.ts +13 -4
  54. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/features/browser.ts +8 -0
  57. package/src/types/browser.ts +13 -4
  58. package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
  59. package/src/web/NativeAudioBrowser.ts +193 -60
  60. package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
  61. package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
  62. package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
  63. package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
  64. package/src/web/TrackPlayer/Player.load.test.ts +137 -1
  65. package/src/web/TrackPlayer/Player.ts +256 -37
  66. package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
  67. package/src/web/__tests__/lifecycle.test.ts +345 -0
  68. package/src/web/__tests__/lifecycleHarness.ts +351 -0
  69. package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
  70. package/src/web/__tests__/loadSupersession.test.ts +175 -0
  71. package/src/web/__tests__/retry.test.ts +169 -0
  72. package/src/web/browser/BrowserManager.ts +32 -15
  73. package/src/web/browser/SearchManager.test.ts +75 -0
  74. package/src/web/browser/SearchManager.ts +23 -26
  75. package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
  76. package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
  77. package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
  78. package/src/web/http/RequestConfigBuilder.test.ts +241 -1
  79. package/src/web/http/RequestConfigBuilder.ts +140 -33
  80. package/src/web/http/followMediaRedirect.test.ts +289 -0
  81. package/src/web/http/followMediaRedirect.ts +253 -0
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vitest'
2
2
  import type { Playback } from '../../features'
3
3
  import type { Track } from '../../types'
4
4
  import { Player } from './Player'
@@ -84,3 +84,139 @@ describe('Player.load rejection staleness', () => {
84
84
  expect(player.read().state).toBe('error')
85
85
  })
86
86
  })
87
+
88
+ /**
89
+ * The URL handed to Shaka can differ from the one the caller queued — a
90
+ * followed redirect, or a `transform` that rewrites it. Callers address tracks
91
+ * by their own `src` (mapping it back to their own content id, say), so a load
92
+ * must not rewrite the track's identity to a transport detail.
93
+ */
94
+ describe('Player.load track identity', () => {
95
+ class SrcCapturingPlayer extends Player {
96
+ loadedSrc?: string
97
+
98
+ constructor() {
99
+ super()
100
+ this.element = {
101
+ play: () => Promise.resolve(),
102
+ pause: () => {}
103
+ } as unknown as HTMLMediaElement
104
+ this.player = {
105
+ load: (src: string) => {
106
+ this.loadedSrc = src
107
+ return Promise.resolve()
108
+ },
109
+ unload: () => Promise.resolve()
110
+ } as unknown as typeof this.player
111
+ }
112
+
113
+ headers(): Record<string, string> | undefined {
114
+ return this.mediaHeaders
115
+ }
116
+ }
117
+
118
+ const queued = {
119
+ id: 't1',
120
+ src: 'https://api.example.com/stream/content/7',
121
+ title: 'T1'
122
+ } as Track
123
+ const resolved = 'https://cdn.example.com/track.mp3?sig=abc'
124
+
125
+ it('plays the resolved url but keeps the queued track as current', async () => {
126
+ const player = new SrcCapturingPlayer()
127
+
128
+ player.load(queued, undefined, { src: resolved })
129
+ await tick()
130
+
131
+ expect(player.loadedSrc).toBe(resolved)
132
+ expect(player.current?.src).toBe(queued.src)
133
+ })
134
+
135
+ it('reports the queued track to onLoaded', async () => {
136
+ const player = new SrcCapturingPlayer()
137
+ const onLoaded = vi.fn()
138
+
139
+ player.load(queued, onLoaded, { src: resolved })
140
+ await tick()
141
+
142
+ expect(onLoaded).toHaveBeenCalledWith(
143
+ expect.objectContaining({ src: queued.src })
144
+ )
145
+ })
146
+
147
+ it('falls back to the track src when no override is given', async () => {
148
+ const player = new SrcCapturingPlayer()
149
+
150
+ player.load(queued)
151
+ await tick()
152
+
153
+ expect(player.loadedSrc).toBe(queued.src)
154
+ expect(player.current?.src).toBe(queued.src)
155
+ })
156
+
157
+ it('stores headers for the request filter, ignoring an empty set', async () => {
158
+ const player = new SrcCapturingPlayer()
159
+
160
+ player.load(queued, undefined, { headers: { Authorization: 'Bearer t' } })
161
+ await tick()
162
+ expect(player.headers()).toEqual({ Authorization: 'Bearer t' })
163
+
164
+ player.load(queued, undefined, { headers: {} })
165
+ await tick()
166
+ expect(player.headers()).toBeUndefined()
167
+ })
168
+ })
169
+
170
+ /**
171
+ * A track that fails to load is still the active track. Native derives the
172
+ * active track from the queue, so an app always has something to attach a
173
+ * playback error to; web tying it to load success meant a failed track left
174
+ * `getActiveTrack()` undefined, and UIs keyed on it rendered nothing at all —
175
+ * the error had nowhere to appear.
176
+ */
177
+ describe('Player.load active track on failure', () => {
178
+ const track = (n: number): Track =>
179
+ ({
180
+ id: `t${n}`,
181
+ src: `https://example.com/${n}.mp3`,
182
+ title: `T${n}`
183
+ }) as Track
184
+
185
+ it('keeps the failed track as current so an error has a home', async () => {
186
+ const player = new LoadTestPlayer()
187
+
188
+ player.load(track(1))
189
+ expect(player.current?.src).toBe(track(1).src)
190
+
191
+ player.rejectLoad(0, new Error('401'))
192
+ await tick()
193
+
194
+ expect(player.current?.src).toBe(track(1).src)
195
+ expect(player.read().state).toBe('error')
196
+ })
197
+
198
+ it('reflects the newest attempt when a load supersedes another', async () => {
199
+ const player = new LoadTestPlayer()
200
+
201
+ player.load(track(1))
202
+ player.load(track(2))
203
+
204
+ expect(player.current?.src).toBe(track(2).src)
205
+
206
+ // the superseded load rejecting must not resurrect the older track
207
+ player.rejectLoad(0, new Error('stale'))
208
+ await tick()
209
+
210
+ expect(player.current?.src).toBe(track(2).src)
211
+ })
212
+
213
+ it('is set for an unplayable track with no src', async () => {
214
+ const player = new LoadTestPlayer()
215
+
216
+ player.load({ id: 'x', title: 'No Src' } as Track)
217
+ await tick()
218
+
219
+ expect(player.current?.id).toBe('x')
220
+ expect(player.read().state).toBe('error')
221
+ })
222
+ })
@@ -9,6 +9,7 @@ import type {
9
9
  import type { Track } from '../../types'
10
10
  import type { PlaybackEvent } from './PlaybackStateMachine'
11
11
  import { playbackErrorKind } from '../playbackErrorKind'
12
+ import { LoadAttempt } from './LoadAttempt'
12
13
  import { nextPlaybackState } from './PlaybackStateMachine'
13
14
  import { SetupNotCalledError } from './SetupNotCalledError'
14
15
  import { State } from './State'
@@ -32,6 +33,35 @@ interface ShakaError {
32
33
  data?: unknown[]
33
34
  }
34
35
 
36
+ /** Per-load overrides that don't belong on the track itself. */
37
+ export interface LoadOptions {
38
+ /**
39
+ * The attempt this load belongs to, when a subclass began one before
40
+ * resolving the URL. Internal plumbing: without it a direct `load()` is the
41
+ * outermost call and starts its own, so two of them supersede each other.
42
+ */
43
+ attempt?: LoadAttempt
44
+ /** Headers resolved from the media configuration, applied to Shaka's requests. */
45
+ headers?: Record<string, string>
46
+ /**
47
+ * The URL to play, when it differs from `track.src` — a followed redirect,
48
+ * say. Kept here rather than rewritten onto the track so the caller's own
49
+ * identity for it survives the load.
50
+ */
51
+ src?: string
52
+ }
53
+
54
+ /** The mutable request Shaka hands to a networking-engine request filter. */
55
+ interface ShakaRequest {
56
+ headers: Record<string, string>
57
+ }
58
+
59
+ /**
60
+ * `shaka.util.Error.Code.LOAD_INTERRUPTED`. Inlined because shaka is imported
61
+ * for types only here, as the codes in `playbackErrorKind.ts` are.
62
+ */
63
+ const LOAD_INTERRUPTED = 7000
64
+
35
65
  interface ShakaBufferingEvent extends CustomEvent {
36
66
  detail: {
37
67
  buffering: boolean
@@ -48,6 +78,12 @@ export class Player {
48
78
  protected _isStopped = false
49
79
  protected _loadInProgress = false
50
80
  protected _pendingSeek: number | undefined
81
+ /**
82
+ * Headers for the track currently loaded, applied by the request filter
83
+ * installed in `setupPlayer`. On the instance rather than per request so the
84
+ * manifest, its segments and any key requests all carry them.
85
+ */
86
+ protected mediaHeaders?: Record<string, string>
51
87
 
52
88
  // current getter/setter
53
89
  public get current(): Track | undefined {
@@ -125,6 +161,17 @@ export class Player {
125
161
  await player.attach(element)
126
162
  this.player = player
127
163
 
164
+ // Apply the media config's headers to Shaka's requests, as native does to
165
+ // the AVURLAsset / ExoPlayer DataSpec. Without this they resolve and are
166
+ // then dropped, and authenticated media 401s on web alone.
167
+ player
168
+ .getNetworkingEngine()
169
+ ?.registerRequestFilter((_type: unknown, request: ShakaRequest) => {
170
+ const headers = this.mediaHeaders
171
+ if (!headers) return
172
+ request.headers = { ...request.headers, ...headers }
173
+ })
174
+
128
175
  // Listen for relevant events
129
176
  player.addEventListener('error', (event: Event) => {
130
177
  const errorEvent = event as ShakaErrorEvent
@@ -137,15 +184,27 @@ export class Player {
137
184
  element.addEventListener('playing', () =>
138
185
  this.dispatch({ type: 'playing' })
139
186
  )
140
- element.addEventListener('pause', () =>
187
+ element.addEventListener('pause', () => {
188
+ // A track playing out fires `pause` and then `ended`. Only the `ended`
189
+ // is meaningful — reporting `paused` first flashes a state Android never
190
+ // reports, mid-queue or at the true end.
191
+ if (element.ended) return
141
192
  this.dispatch({ type: 'paused', hasAsset: this.current !== undefined })
142
- )
193
+ })
143
194
 
144
- player.addEventListener('loading', () =>
195
+ player.addEventListener('loading', () => {
145
196
  this.dispatch({ type: 'trackLoading' })
146
- )
197
+ // Every load passes through `buffering`, as ExoPlayer's always does via
198
+ // STATE_BUFFERING. Shaka only fires its own `buffering` event when a load
199
+ // actually stalls, so a load served from cache would otherwise skip a
200
+ // state native always reports.
201
+ this.dispatch({ type: 'waiting' })
202
+ })
147
203
  player.addEventListener('loaded', () =>
148
- this.dispatch({ type: 'loadSeekCompleted' })
204
+ this.dispatch({
205
+ type: 'loadSeekCompleted',
206
+ playWhenReady: this.playWhenReady
207
+ })
149
208
  )
150
209
 
151
210
  player.addEventListener('buffering', (event: Event) => {
@@ -153,7 +212,10 @@ export class Player {
153
212
  this.dispatch(
154
213
  bufferingEvent.detail.buffering === true
155
214
  ? { type: 'waiting' }
156
- : { type: 'bufferingSufficient' }
215
+ : {
216
+ type: 'bufferingSufficient',
217
+ playWhenReady: this.playWhenReady
218
+ }
157
219
  )
158
220
  })
159
221
 
@@ -208,6 +270,24 @@ export class Player {
208
270
  }
209
271
 
210
272
  protected onError(shakaError: ShakaError): void {
273
+ // An interrupted load is not a failure. Shaka raises it when a `load()` is
274
+ // cut short by a newer `load()` or an `unload()` — always something we
275
+ // asked for — and names it accordingly: `createAbortLoadError_()`. Its own
276
+ // code skips its cleanup unload for this code too (player.js:2010).
277
+ //
278
+ // Recognising it here rather than pre-emptively invalidating the load keeps
279
+ // the two independent: any teardown, in any order, can abort a load without
280
+ // the abort reaching a consumer as an error. Eagerly unloading to stop the
281
+ // outgoing track used to flash an error banner on every fast track switch.
282
+ if (shakaError.code === LOAD_INTERRUPTED) {
283
+ console.debug('Load interrupted by a newer load or an unload')
284
+ return
285
+ }
286
+
287
+ // Before the unload below resets it: a retry resumes from where playback
288
+ // failed, as native does.
289
+ this.rememberResumePosition()
290
+
211
291
  // unload the current track to allow for clean playback on other
212
292
  this.player?.unload().catch((err) => {
213
293
  console.error(`Error unloading player on 'onError'`, err)
@@ -236,18 +316,57 @@ export class Player {
236
316
  console.debug('Error code', shakaError.code, 'object', shakaError)
237
317
  }
238
318
 
239
- /** Invalidates the .then/.catch of superseded loads — a Shaka load
240
- * interrupted by stop() or a newer load() rejects, and only the active load
241
- * may surface that (or run its onLoaded side effects). */
242
- private _loadGeneration = 0
319
+ /**
320
+ * The load attempt that owns the player, or none. See {@link LoadAttempt}.
321
+ */
322
+ private _attempt: LoadAttempt | undefined
323
+
324
+ /**
325
+ * Starts an attempt, superseding whatever was in flight.
326
+ *
327
+ * Called by the outermost `load()` so supersession is registered when the
328
+ * caller asks for it, not when some inner stage happens to reach it.
329
+ */
330
+ protected beginLoadAttempt(): LoadAttempt {
331
+ this._attempt?.cancel()
332
+ const attempt = new LoadAttempt()
333
+ this._attempt = attempt
334
+ return attempt
335
+ }
336
+
337
+ /** Abandons the attempt in flight without starting another — `stop()`. */
338
+ protected cancelLoadAttempt(): void {
339
+ this._attempt?.cancel()
340
+ this._attempt = undefined
341
+ }
243
342
 
244
- public load(track: Track, onLoaded?: (track: Track) => void): void {
343
+ public load(
344
+ track: Track,
345
+ onLoaded?: (track: Track) => void,
346
+ options?: LoadOptions
347
+ ): void {
245
348
  const player = this.requirePlayer()
246
349
  this._isStopped = false
247
350
  this._loadInProgress = true
248
- const generation = ++this._loadGeneration
249
-
250
- if (!track.src) {
351
+ const headers = options?.headers
352
+ this.mediaHeaders =
353
+ headers && Object.keys(headers).length > 0 ? headers : undefined
354
+ // What Shaka plays may differ from what the caller queued; `track` is left
355
+ // alone so `current` and `getActiveTrack()` keep the caller's identity for
356
+ // it rather than a transport detail.
357
+ const playbackSrc = options?.src ?? track.src
358
+ // Current on the attempt, not on success — native derives the active track
359
+ // from the queue, so a failed load is still the active track there and its
360
+ // error has somewhere to show. `getActiveTrackIndex()` already reported the
361
+ // queue's index either way; this makes the pair agree.
362
+ this.current = track
363
+ // The attempt a subclass began before resolving the URL, so its resolution
364
+ // stage and both continuations below share one token. Passed rather than
365
+ // read off the instance: a direct `load()` is itself the outermost call and
366
+ // must start a fresh attempt, superseding whatever was in flight.
367
+ const attempt = options?.attempt ?? this.beginLoadAttempt()
368
+
369
+ if (!playbackSrc) {
251
370
  this._loadInProgress = false
252
371
  this._pendingSeek = undefined
253
372
  const error: PlaybackError = {
@@ -263,11 +382,10 @@ export class Player {
263
382
  }
264
383
 
265
384
  player
266
- .load(track.src)
385
+ .load(playbackSrc)
267
386
  .then(() => {
268
- if (generation !== this._loadGeneration || this._isStopped) return
387
+ if (!attempt.isCurrent) return
269
388
  this._loadInProgress = false
270
- this.current = track
271
389
  onLoaded?.(track)
272
390
 
273
391
  // Execute any pending seek that arrived during loading
@@ -282,7 +400,7 @@ export class Player {
282
400
  }
283
401
  })
284
402
  .catch((err: unknown) => {
285
- if (generation !== this._loadGeneration || this._isStopped) return
403
+ if (!attempt.isCurrent) return
286
404
  this._loadInProgress = false
287
405
  this._pendingSeek = undefined
288
406
  this.onError(this.toNormalizedError(err))
@@ -292,8 +410,14 @@ export class Player {
292
410
  public stop(onComplete?: () => void): void {
293
411
  const player = this.requirePlayer()
294
412
 
413
+ // Whatever was loading no longer owns the player: its continuations, at
414
+ // either boundary, must not revive it.
415
+ this.cancelLoadAttempt()
416
+
295
417
  // Match Android: stop sets playWhenReady=false and state=stopped,
296
- // but keeps the current track so play() can resume.
418
+ // but keeps the current track so play() can resume — from where it
419
+ // stopped, which ExoPlayer preserves across its own stop/prepare pair.
420
+ this.rememberResumePosition()
297
421
  this._isStopped = true
298
422
  this._loadInProgress = false
299
423
  this._pendingSeek = undefined
@@ -309,37 +433,132 @@ export class Player {
309
433
  })
310
434
  }
311
435
 
436
+ /**
437
+ * Where playback was when it failed or was stopped, so a re-prepare can
438
+ * resume rather than restart.
439
+ *
440
+ * Kept against the track it belongs to rather than as a bare number, so it
441
+ * self-invalidates when a different track becomes current and cannot be
442
+ * applied to the wrong one.
443
+ */
444
+ private _resume: { track: Track; position: number } | undefined
445
+
446
+ /**
447
+ * Records the current position, when there is one worth recording.
448
+ *
449
+ * A zero is never recorded: `onError` runs for a *load* failure too, where
450
+ * the element has just been torn down and reads 0, and overwriting a good
451
+ * position with that made a failed retry lose the place a successful one
452
+ * would have resumed from.
453
+ */
454
+ private rememberResumePosition(): void {
455
+ const position = this.element?.currentTime
456
+ const track = this.current
457
+ if (!track || typeof position !== 'number' || position <= 0) return
458
+ this._resume = { track, position }
459
+ }
460
+
461
+ /**
462
+ * Android's `prepare()`, which both `play()` and `retry()` call.
463
+ *
464
+ * It early-returns unless ExoPlayer is STATE_IDLE, so it reconnects after an
465
+ * error or a stop and never re-buffers a healthy stream; `error`/`_isStopped`
466
+ * is web's equivalent of idle. Re-preparing re-resolves the URL, which is the
467
+ * whole point — a signed URL or token may have expired since the failure, and
468
+ * replaying the cached one just fails again. iOS says the same thing in its
469
+ * own `retry()`: "Re-resolve rather than replay the cached URL".
470
+ *
471
+ * Returns whether it handled the call, so `play()` knows not to also poke the
472
+ * element.
473
+ */
474
+ protected reprepare(): boolean {
475
+ if (!this.current) return false
476
+ if (this.state.state !== State.Error && !this._isStopped) return false
477
+
478
+ // Not for live: `currentTime` there is elapsed time on a connection that no
479
+ // longer exists, and a fresh connection already *is* the live edge. Seeking
480
+ // to the old value asks for a point the new stream does not have. The
481
+ // track's own `live` declaration is the gate, as it is in `seekToLiveEdge`
482
+ // and on both native platforms.
483
+ const remembered =
484
+ this._resume?.track === this.current ? this._resume.position : undefined
485
+ const resume = this.current.live === true ? undefined : remembered
486
+
487
+ this.reloadCurrent(resume)
488
+ return true
489
+ }
490
+
312
491
  public play(): void {
313
492
  const element = this.requireElement()
314
493
  this.playWhenReady = true
315
494
 
316
- if (this.state.state === State.Error && this.current) {
317
- this.reloadCurrent()
318
- return
319
- }
495
+ // Matches Android's play(): set the intent, then prepare — which does
496
+ // something only when idle, i.e. after an error or a stop.
497
+ if (this.reprepare()) return
320
498
 
321
- // Match Android: play() after stop() re-prepares the current track
322
- if (this._isStopped && this.current) {
323
- this.reloadCurrent()
324
- return
325
- }
499
+ element.play().catch((err: unknown) => this.onPlayRejected(err))
500
+ }
501
+
502
+ /**
503
+ * A rejected `element.play()`. Web-only: neither native platform can refuse
504
+ * a play intent, so there is no parity target here.
505
+ *
506
+ * `AbortError` means a newer load interrupted this one — that load owns the
507
+ * state and the intent stands. Anything else, autoplay policy above all, is
508
+ * the browser refusing: the intent cannot be honoured, so it is cleared and
509
+ * the state settles rather than stranding on `loading`/`buffering`, which is
510
+ * where suppressing `ready` under `playWhenReady` would otherwise leave it.
511
+ */
512
+ private onPlayRejected(err: unknown): void {
513
+ console.error(err)
514
+ const name =
515
+ typeof err === 'object' && err !== null && 'name' in err
516
+ ? (err as { name?: unknown }).name
517
+ : undefined
518
+ if (name === 'AbortError') return
326
519
 
327
- element.play().catch((err: unknown) => console.error(err))
520
+ this.playWhenReady = false
521
+ this.dispatch({ type: 'paused', hasAsset: this.current !== undefined })
328
522
  }
329
523
 
330
524
  /**
331
- * Reloads the current track. `current` holds the *resolved* track, so
332
- * subclasses that resolve media URLs override this to reload from the
333
- * original source instead — re-feeding `current` into `load()` would
334
- * re-resolve an already-resolved URL and leak it into the queue.
525
+ * The track a reload re-feeds. `current` holds it as the caller queued it —
526
+ * `load()` takes the resolved URL separately, via `LoadOptions.src` — so
527
+ * re-feeding it is safe.
528
+ *
529
+ * The overridable part of {@link reloadCurrent}, rather than the whole
530
+ * method: a subclass backed by a queue changes only where the track comes
531
+ * from, and the reload itself stays in one place.
335
532
  */
336
- protected reloadCurrent(): void {
337
- if (this.current) this.load(this.current)
533
+ protected trackToReload(): Track | undefined {
534
+ return this.current
535
+ }
536
+
537
+ /** Reloads the track {@link trackToReload} names, resuming if asked to. */
538
+ protected reloadCurrent(resumePosition?: number): void {
539
+ const track = this.trackToReload()
540
+ if (!track) return
541
+ this.load(
542
+ track,
543
+ resumePosition === undefined
544
+ ? undefined
545
+ : () => this.seekTo(resumePosition)
546
+ )
338
547
  }
339
548
 
549
+ /**
550
+ * Android's `retry()` is `player.prepare()` — the same call `play()` makes,
551
+ * without setting the intent. So this is `play()` minus the intent.
552
+ *
553
+ * It is emphatically not `shaka.Player.retryStreaming()`, which this used to
554
+ * call: that returns false and does nothing at all unless the load mode is
555
+ * MEDIA_SOURCE, so for a progressive file — the case a signed URL is for — it
556
+ * was a silent no-op, while the `play()` the same docs offer as the
557
+ * alternative recovered properly.
558
+ */
340
559
  public retry(): void {
341
- const player = this.requirePlayer()
342
- player.retryStreaming()
560
+ this.requirePlayer()
561
+ this.reprepare()
343
562
  }
344
563
 
345
564
  public pause(): void {
@@ -22,18 +22,33 @@ export class QueuePlayer extends Player {
22
22
  // was a silent pause) and armed load()'s auto-play with phantom intent.
23
23
  // Cleared before the state lands so consumers observe the native order
24
24
  // (intent → state → queueEnded), independent of onQueueEnded overrides.
25
- // Per-track ends that advance the queue keep the intent.
26
- if (state === State.Ended && this.endsQueue()) {
27
- this.playWhenReady = false
25
+ if (state !== State.Ended) {
26
+ super.applyState(state)
27
+ return
28
28
  }
29
29
 
30
- super.applyState(state)
31
-
32
- // dispatch() already gates on _isStopped before reaching applyState, so a
33
- // natural end while stopped never advances the queue.
34
- if (state === State.Ended) {
30
+ // A track ending mid-queue is not an `ended` playback state: ExoPlayer
31
+ // reaches STATE_ENDED only at the end of the *playlist*, and an advance
32
+ // between items stays READY/BUFFERING. Web loads one track at a time, so
33
+ // the element's own `ended` arrives every time — reporting it flashed a
34
+ // terminal state, and dropped `PlayingState.playing` to false, between
35
+ // every pair of tracks. Repeat-one counts as mid-queue for the same
36
+ // reason: native reports nothing there either.
37
+ if (this.endsQueue()) {
38
+ // The real end: the intent goes with it, and the state is genuinely ended.
39
+ this.playWhenReady = false
40
+ super.applyState(state)
41
+ // dispatch() already gates on _isStopped before reaching applyState, so a
42
+ // natural end while stopped never advances the queue.
35
43
  this.onTrackEnded()
44
+ return
36
45
  }
46
+
47
+ // Suppressing `ended` is only safe when something takes over the state. A
48
+ // handler that declines to advance — the sleep timer set to end-of-track —
49
+ // leaves nothing to move it, and the player would sit reporting `playing`
50
+ // with nothing playing. Report the end in that case, as before.
51
+ if (!this.onTrackEnded()) super.applyState(state)
37
52
  }
38
53
 
39
54
  /** True when a natural end has nowhere to go: not repeating, no next track. */
@@ -45,18 +60,22 @@ export class QueuePlayer extends Player {
45
60
  )
46
61
  }
47
62
 
48
- protected onTrackEnded() {
63
+ /**
64
+ * @returns whether it started another load, and so owns the playback state
65
+ * from here. `false` means the caller still has to report the end itself.
66
+ */
67
+ protected onTrackEnded(): boolean {
49
68
  if (this.queue.repeatMode === RepeatMode.Track) {
50
- if (this.queue.currentIndex !== undefined) {
51
- this.goToIndex(this.queue.currentIndex)
52
- }
53
- return
69
+ if (this.queue.currentIndex === undefined) return false
70
+ this.goToIndex(this.queue.currentIndex)
71
+ return true
54
72
  }
55
73
  if (this.endsQueue()) {
56
74
  this.onQueueEnded()
57
- return
75
+ return false
58
76
  }
59
77
  this.skipToNext()
78
+ return true
60
79
  }
61
80
 
62
81
  /**