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
|
@@ -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({
|
|
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
|
-
: {
|
|
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
|
-
/**
|
|
240
|
-
*
|
|
241
|
-
|
|
242
|
-
private
|
|
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(
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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(
|
|
385
|
+
.load(playbackSrc)
|
|
267
386
|
.then(() => {
|
|
268
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
520
|
+
this.playWhenReady = false
|
|
521
|
+
this.dispatch({ type: 'paused', hasAsset: this.current !== undefined })
|
|
328
522
|
}
|
|
329
523
|
|
|
330
524
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
if (state !== State.Ended) {
|
|
26
|
+
super.applyState(state)
|
|
27
|
+
return
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
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
|
/**
|