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,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.followMediaRedirect = followMediaRedirect;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves a media URL that is authenticated by a header.
|
|
9
|
+
*
|
|
10
|
+
* Shaka only routes manifest requests through its networking engine, where a
|
|
11
|
+
* request filter can attach headers. A progressive file is played by assigning
|
|
12
|
+
* `mediaElement.src`, and a media element cannot send custom headers — so such
|
|
13
|
+
* a URL is unplayable that way. Following the redirect here yields the signed,
|
|
14
|
+
* public URL these endpoints hand back, which needs no auth of its own.
|
|
15
|
+
*
|
|
16
|
+
* Best effort and bounded: anything that fails — including taking too long, see
|
|
17
|
+
* {@link PROBE_TIMEOUT_MS} — falls back to the original URL, so the load fails
|
|
18
|
+
* or succeeds on its own terms rather than ours. It must never be the reason a
|
|
19
|
+
* load hangs: `load()` awaits this before Shaka sees the URL at all.
|
|
20
|
+
*
|
|
21
|
+
* Gated, because the probe is not free: a HEAD at a third-party stream host
|
|
22
|
+
* costs a CORS preflight, and most will refuse it, so an app that never
|
|
23
|
+
* authenticates its media would pay a round trip on every play for a result it
|
|
24
|
+
* always discards. It runs only for headers that look like media credentials
|
|
25
|
+
* ({@link isMediaAuth}), on media a media element actually has to play
|
|
26
|
+
* ({@link isManifestUrl}), and never on a URL the browser resolves locally.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** URLs the browser resolves locally; there is no redirect to follow. */
|
|
30
|
+
const isLocalUrl = url => /^(file|blob|data|mediasource):/i.test(url);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extensions Shaka types as a manifest, from its own `EXTENSIONS_TO_MIME_TYPES_`
|
|
34
|
+
* map: HLS, DASH and Smooth Streaming.
|
|
35
|
+
*/
|
|
36
|
+
const MANIFEST_EXTENSIONS = new Set(['m3u8', 'mpd', 'ism']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether Shaka will parse this as a manifest rather than hand it to the media
|
|
40
|
+
* element — in which case the probe has nothing to offer and something to cost.
|
|
41
|
+
*
|
|
42
|
+
* A manifest goes through Shaka's networking engine, where the request filter
|
|
43
|
+
* attaches the headers to the manifest, its segments and any key requests. The
|
|
44
|
+
* probe cannot improve on that, and it actively breaks it: on a cross-origin
|
|
45
|
+
* redirect the probe clears the headers, so the segments that Android still
|
|
46
|
+
* authenticates would go out bare and 401. So this is a correctness fix as much
|
|
47
|
+
* as it is one fewer round trip.
|
|
48
|
+
*
|
|
49
|
+
* Extension-based, and therefore partial — Shaka guesses the same way
|
|
50
|
+
* (`NetworkingUtils.getExtension`), but plenty of HLS endpoints have no
|
|
51
|
+
* extension at all. Those still get probed, which is the status quo rather than
|
|
52
|
+
* a regression. Nothing on `Track` carries a MIME type to do better with.
|
|
53
|
+
*/
|
|
54
|
+
const isManifestUrl = url => {
|
|
55
|
+
const path = url.split(/[?#]/)[0] ?? '';
|
|
56
|
+
const filename = path.slice(path.lastIndexOf('/') + 1);
|
|
57
|
+
const pieces = filename.split('.');
|
|
58
|
+
return pieces.length > 1 && MANIFEST_EXTENSIONS.has(pieces[pieces.length - 1].toLowerCase());
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* How long the probe may take before the original URL is played instead.
|
|
62
|
+
*
|
|
63
|
+
* The same 8s the Android media client allows a connect or a read
|
|
64
|
+
* (`MediaFactory.mediaHttpClient`), which is generous for a HEAD but errs the
|
|
65
|
+
* safe way: a slow-but-working auth endpoint should not be abandoned, since
|
|
66
|
+
* giving up means playing a URL that will 401.
|
|
67
|
+
*/
|
|
68
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `AbortSignal.timeout` where it exists (Safari 16+, Chrome 103+, Node 17.3+),
|
|
72
|
+
* else a hand-rolled equivalent, else nothing — an environment with neither
|
|
73
|
+
* still gets the deadline below, just without cancelling the request.
|
|
74
|
+
*/
|
|
75
|
+
const timeoutSignal = ms => {
|
|
76
|
+
if (typeof AbortSignal === 'undefined') return undefined;
|
|
77
|
+
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms);
|
|
78
|
+
if (typeof AbortController === 'undefined') return undefined;
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
setTimeout(() => controller.abort(), ms);
|
|
81
|
+
return controller.signal;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The probe, with a deadline. `load()` awaits this before Shaka is asked to
|
|
86
|
+
* play anything, so an unreachable host would otherwise stall the load
|
|
87
|
+
* indefinitely — no audio, and no error to show for it.
|
|
88
|
+
*
|
|
89
|
+
* Two mechanisms, each with its own job: the signal cancels the request, so a
|
|
90
|
+
* dead host does not hold a connection open behind us, and the race bounds *us*
|
|
91
|
+
* even where the fetch implementation ignores the signal. Whichever fires, the
|
|
92
|
+
* caller treats it as any other probe failure and plays the original URL.
|
|
93
|
+
*/
|
|
94
|
+
const fetchWithDeadline = async (doFetch, url, headers, timeoutMs) => {
|
|
95
|
+
let timer;
|
|
96
|
+
try {
|
|
97
|
+
return await Promise.race([doFetch(url, {
|
|
98
|
+
method: 'HEAD',
|
|
99
|
+
headers,
|
|
100
|
+
redirect: 'follow',
|
|
101
|
+
signal: timeoutSignal(timeoutMs)
|
|
102
|
+
}), new Promise((_resolve, reject) => {
|
|
103
|
+
timer = setTimeout(() => reject(new Error(`Media redirect probe timed out (${timeoutMs}ms)`)), timeoutMs);
|
|
104
|
+
})]);
|
|
105
|
+
} finally {
|
|
106
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether these headers look like a media credential — the only thing the
|
|
112
|
+
* probe can fix, and the only thing worth a preflight.
|
|
113
|
+
*
|
|
114
|
+
* A header the `media` layer set is media-specific by construction. Otherwise
|
|
115
|
+
* only `Authorization` counts: an app authenticating one API for browse and
|
|
116
|
+
* media alike puts it on the shared `request` layer, and that is the common
|
|
117
|
+
* shape. Shared plumbing that also rides browse requests — an `X-Client-Version`
|
|
118
|
+
* — is not a credential and must not trigger the probe.
|
|
119
|
+
*
|
|
120
|
+
* The residual case is a shared layer carrying a custom auth header, say
|
|
121
|
+
* `X-Api-Key`, for media that needs it: move it to the `media` layer and the
|
|
122
|
+
* probe runs.
|
|
123
|
+
*/
|
|
124
|
+
const isMediaAuth = (headers, mediaLayerHeaders) => mediaLayerHeaders || Object.keys(headers).some(key => key.toLowerCase() === 'authorization');
|
|
125
|
+
/**
|
|
126
|
+
* Origin of an http(s) URL, or undefined when it cannot be determined.
|
|
127
|
+
*
|
|
128
|
+
* Relative URLs are resolved against the document, because that is what the
|
|
129
|
+
* probe's own `fetch` does — a `src` of `/api/authed/7` is a request to the
|
|
130
|
+
* page's origin, so the credential went there. Without this a relative `src`
|
|
131
|
+
* has no origin, never matches the absolute URL the response reports, and the
|
|
132
|
+
* headers are dropped from every load that uses one.
|
|
133
|
+
*
|
|
134
|
+
* Parsed rather than pattern-matched so the comparison normalizes the way
|
|
135
|
+
* Android's `CrossOriginHeaderStripper.sameOrigin` does: `URL.host` omits a
|
|
136
|
+
* default port, so `https://a.com` and `https://a.com:443` are one origin, and
|
|
137
|
+
* it excludes userinfo, so `https://user@a.com` is not a third.
|
|
138
|
+
*/
|
|
139
|
+
const originOf = url => {
|
|
140
|
+
try {
|
|
141
|
+
const base = typeof location === 'undefined' ? undefined : location.href;
|
|
142
|
+
const {
|
|
143
|
+
protocol,
|
|
144
|
+
host
|
|
145
|
+
} = new URL(url, base);
|
|
146
|
+
if (protocol !== 'http:' && protocol !== 'https:') return undefined;
|
|
147
|
+
return `${protocol}//${host}`;
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether the redirect left the origin the credential was addressed to.
|
|
155
|
+
*
|
|
156
|
+
* Only when both origins are known and differ. An origin we could not work out
|
|
157
|
+
* — a relative URL with no document base, outside a browser — is not evidence
|
|
158
|
+
* of a hop, and treating it as one is how the headers went missing before.
|
|
159
|
+
*/
|
|
160
|
+
const leftTheOrigin = (from, to) => {
|
|
161
|
+
const origin = originOf(from);
|
|
162
|
+
return origin !== undefined && originOf(to) !== origin;
|
|
163
|
+
};
|
|
164
|
+
async function followMediaRedirect(url, headers, {
|
|
165
|
+
fetchImpl,
|
|
166
|
+
mediaLayerHeaders = false,
|
|
167
|
+
timeoutMs = PROBE_TIMEOUT_MS
|
|
168
|
+
} = {}) {
|
|
169
|
+
if (!headers || Object.keys(headers).length === 0) return {
|
|
170
|
+
src: url,
|
|
171
|
+
headers
|
|
172
|
+
};
|
|
173
|
+
if (!isMediaAuth(headers, mediaLayerHeaders)) return {
|
|
174
|
+
src: url,
|
|
175
|
+
headers
|
|
176
|
+
};
|
|
177
|
+
if (isManifestUrl(url)) return {
|
|
178
|
+
src: url,
|
|
179
|
+
headers
|
|
180
|
+
};
|
|
181
|
+
if (isLocalUrl(url)) return {
|
|
182
|
+
src: url,
|
|
183
|
+
headers
|
|
184
|
+
};
|
|
185
|
+
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
186
|
+
if (typeof doFetch !== 'function') return {
|
|
187
|
+
src: url,
|
|
188
|
+
headers
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
// HEAD walks the chain without tripping download counting, which keys off
|
|
192
|
+
// ranged GETs. Deadlined, because the load waits on it.
|
|
193
|
+
const response = await fetchWithDeadline(doFetch, url, headers, timeoutMs);
|
|
194
|
+
const src = response.url || url;
|
|
195
|
+
|
|
196
|
+
// The status belongs to the *last* hop, so it only speaks for the
|
|
197
|
+
// authenticating endpoint when there was no redirect. Once one has
|
|
198
|
+
// happened, hop one authenticated and handed us its target, and a refusal
|
|
199
|
+
// past that is routinely about the method rather than the credential:
|
|
200
|
+
// presigned S3 and GCS URLs sign it — SigV4's canonical request opens with
|
|
201
|
+
// HTTPMethod, GCS V4's with HTTP_VERB — so a URL signed for GET answers a
|
|
202
|
+
// HEAD with 403. Keep the signed URL, which is the whole point of the walk,
|
|
203
|
+
// and let a real error surface through the player.
|
|
204
|
+
if (!response.ok && src === url) return {
|
|
205
|
+
src: url,
|
|
206
|
+
headers
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
src,
|
|
210
|
+
headers: leftTheOrigin(url, src) ? undefined : headers
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
return {
|
|
214
|
+
src: url,
|
|
215
|
+
headers
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=followMediaRedirect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["isLocalUrl","url","test","MANIFEST_EXTENSIONS","Set","isManifestUrl","path","split","filename","slice","lastIndexOf","pieces","length","has","toLowerCase","PROBE_TIMEOUT_MS","timeoutSignal","ms","AbortSignal","undefined","timeout","AbortController","controller","setTimeout","abort","signal","fetchWithDeadline","doFetch","headers","timeoutMs","timer","Promise","race","method","redirect","_resolve","reject","Error","clearTimeout","isMediaAuth","mediaLayerHeaders","Object","keys","some","key","originOf","base","location","href","protocol","host","URL","leftTheOrigin","from","to","origin","followMediaRedirect","fetchImpl","src","globalThis","fetch","response","ok"],"sourceRoot":"../../../../src","sources":["web/http/followMediaRedirect.ts"],"mappings":"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","ignoreList":[]}
|
|
@@ -6,6 +6,7 @@ import { classifyTrackNavigation } from './browser/classifyTrackNavigation';
|
|
|
6
6
|
import { FavoriteManager } from './browser/FavoriteManager';
|
|
7
7
|
import { NavigationErrorManager } from './browser/NavigationErrorManager';
|
|
8
8
|
import { SearchManager } from './browser/SearchManager';
|
|
9
|
+
import { followMediaRedirect } from './http/followMediaRedirect';
|
|
9
10
|
import { HttpClient } from './http/HttpClient';
|
|
10
11
|
import { RequestConfigBuilder } from './http/RequestConfigBuilder';
|
|
11
12
|
import { NowPlayingManager } from './player/NowPlayingManager';
|
|
@@ -41,7 +42,6 @@ export class NativeAudioBrowser extends QueuePlayer {
|
|
|
41
42
|
// Managers
|
|
42
43
|
|
|
43
44
|
// Player state
|
|
44
|
-
currentLoadId = 0;
|
|
45
45
|
progressTimer = new PlaybackTimer();
|
|
46
46
|
intervalTimer = new PlaybackTimer();
|
|
47
47
|
_online = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
@@ -208,6 +208,13 @@ export class NativeAudioBrowser extends QueuePlayer {
|
|
|
208
208
|
this.onPlaybackError({
|
|
209
209
|
error: newState.error
|
|
210
210
|
});
|
|
211
|
+
} else if (oldState.state === 'error') {
|
|
212
|
+
// Leaving the error state clears it, as it does natively. The getter
|
|
213
|
+
// already reads undefined — consumers subscribe to the event, so without
|
|
214
|
+
// this a stale error outlives the next successful load.
|
|
215
|
+
this.onPlaybackError({
|
|
216
|
+
error: undefined
|
|
217
|
+
});
|
|
211
218
|
}
|
|
212
219
|
}
|
|
213
220
|
/**
|
|
@@ -452,68 +459,98 @@ export class NativeAudioBrowser extends QueuePlayer {
|
|
|
452
459
|
* Supports the transform callback for URL manipulation.
|
|
453
460
|
* Mirrors Android's MediaFactory.getMediaRequestConfig behavior.
|
|
454
461
|
*/
|
|
455
|
-
async
|
|
462
|
+
async resolveMediaRequest(src, track) {
|
|
463
|
+
// The *resolved* request layer, not `configuration.request`: a
|
|
464
|
+
// resolver-only config would otherwise reach media with no baseUrl and no
|
|
465
|
+
// headers. Browse, search and browse artwork all read it this way too.
|
|
466
|
+
//
|
|
467
|
+
// Caught, because unlike those three this one cannot fail the load: a
|
|
468
|
+
// resolver hiccup — a token refresh timing out — would otherwise take down
|
|
469
|
+
// a track whose `src` is already absolute and public and needed no layer at
|
|
470
|
+
// all. Native degrades the same way, `getMediaRequestConfig` returning null
|
|
471
|
+
// and the original URL playing on.
|
|
472
|
+
let request;
|
|
473
|
+
try {
|
|
474
|
+
request = await this.browserManager.resolvedRequestConfig();
|
|
475
|
+
} catch (e) {
|
|
476
|
+
console.error('Failed to resolve the request layer for media', e);
|
|
477
|
+
}
|
|
456
478
|
const {
|
|
457
|
-
request,
|
|
458
479
|
media
|
|
459
480
|
} = this.browserManager.configuration;
|
|
460
|
-
return RequestConfigBuilder.
|
|
481
|
+
return RequestConfigBuilder.resolveMediaRequest(src, request, media, track);
|
|
461
482
|
}
|
|
462
483
|
|
|
463
484
|
/**
|
|
464
|
-
*
|
|
465
|
-
* `current`
|
|
466
|
-
*
|
|
467
|
-
* the queue entry with it.
|
|
485
|
+
* The queue's own entry, so the queue stays the source of truth for what is
|
|
486
|
+
* playing. `current` preserves the track as queued — the resolved URL is
|
|
487
|
+
* passed to `load()` separately — so either is safe to re-feed.
|
|
468
488
|
*/
|
|
469
|
-
|
|
489
|
+
trackToReload() {
|
|
470
490
|
const index = this.queue.currentIndex;
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
super.reloadCurrent();
|
|
476
|
-
}
|
|
491
|
+
const queued = index !== undefined ? this.queue.getTrack(index) : undefined;
|
|
492
|
+
// The queue entry wins, so it stays the source of truth for what is
|
|
493
|
+
// playing; `current` is the fallback for a player loaded without one.
|
|
494
|
+
return queued ?? super.trackToReload();
|
|
477
495
|
}
|
|
478
|
-
load(track, callback) {
|
|
479
|
-
const element = this.requireElement();
|
|
480
|
-
|
|
481
|
-
// Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
|
|
482
|
-
this.nowPlayingManager.clearNowPlayingOverride();
|
|
483
496
|
|
|
484
|
-
|
|
485
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Makes `track` the queue's current entry, emitting the change.
|
|
499
|
+
*
|
|
500
|
+
* Matches Android, where `load()` modifies the queue: an empty queue takes
|
|
501
|
+
* the track as its only entry, a populated one has its current entry
|
|
502
|
+
* replaced.
|
|
503
|
+
*/
|
|
504
|
+
syncQueueTo(track) {
|
|
486
505
|
if (this.queue.length === 0) {
|
|
487
506
|
this.queue.setTracks([track]);
|
|
488
507
|
this.queue.currentIndex = 0;
|
|
489
508
|
this.emitQueueChanged();
|
|
490
|
-
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (this.queue.currentIndex !== undefined && this.queue.getTrack(this.queue.currentIndex) !== track) {
|
|
491
512
|
this.queue.replaceTrack(this.queue.currentIndex, track);
|
|
492
513
|
this.emitQueueChanged();
|
|
493
514
|
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Everything native does in its media-item transition, all of it before any
|
|
519
|
+
* network work: announce the new track, publish its now-playing metadata,
|
|
520
|
+
* cut the outgoing one, and report `loading`.
|
|
521
|
+
*
|
|
522
|
+
* Android's `onMediaItemTransition` fires at `prepare()` and does exactly
|
|
523
|
+
* this, with its LOADING following from `onEvents`; resolution happens later
|
|
524
|
+
* still, inside `TransformingDataSource.open()`. Doing it here rather than
|
|
525
|
+
* after the URL resolves is what gives the resolution window a state, and
|
|
526
|
+
* keeps `activeTrack` ahead of `loading` — the order a consumer that reads
|
|
527
|
+
* `getActiveTrack()` on the loading edge depends on.
|
|
528
|
+
*/
|
|
529
|
+
transitionTo(track) {
|
|
530
|
+
const element = this.requireElement();
|
|
494
531
|
const lastTrack = this.current;
|
|
495
532
|
const lastPosition = element.currentTime;
|
|
496
533
|
const lastIndex = this.queue.lastIndex;
|
|
497
534
|
const currentIndex = this.queue.currentIndex;
|
|
498
535
|
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
536
|
+
// A re-prepare of the item already current is not a transition, and must
|
|
537
|
+
// not be announced as one: ExoPlayer's `prepare()` does not re-fire
|
|
538
|
+
// `onMediaItemTransition` for an unchanged media item, so a consumer that
|
|
539
|
+
// resets on a track change would have its UI wiped by every retry. The
|
|
540
|
+
// `lastTrack === track` payload was the tell.
|
|
541
|
+
const isTransition = this.current !== track;
|
|
503
542
|
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
super.load(resolvedTrack, loadedTrack => {
|
|
543
|
+
// `current` first, so `getActiveTrack()` already reads the new track inside
|
|
544
|
+
// a handler and so `getNowPlaying()` publishes the new track's metadata. It
|
|
545
|
+
// also stops `getActiveTrackIndex()` — which moved with the queue already,
|
|
546
|
+
// synchronously — from disagreeing with it for the length of the
|
|
547
|
+
// resolution.
|
|
548
|
+
this.current = track;
|
|
549
|
+
|
|
550
|
+
// Separately guarded: a throwing consumer handler must not skip the
|
|
551
|
+
// publish, and neither may fail the load.
|
|
552
|
+
if (isTransition) {
|
|
553
|
+
try {
|
|
517
554
|
this.onPlaybackActiveTrackChanged({
|
|
518
555
|
lastTrack,
|
|
519
556
|
lastPosition,
|
|
@@ -521,24 +558,100 @@ export class NativeAudioBrowser extends QueuePlayer {
|
|
|
521
558
|
index: currentIndex,
|
|
522
559
|
track
|
|
523
560
|
});
|
|
524
|
-
|
|
525
|
-
|
|
561
|
+
} catch (error) {
|
|
562
|
+
console.error('Failed to announce the active track change:', error);
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
// Once, here: duration reaches the media session through
|
|
566
|
+
// `updateProgress()` on each tick, so only metadata needs publishing.
|
|
567
|
+
// Android publishes from the same transition handler that announces.
|
|
526
568
|
const nowPlaying = this.getNowPlaying();
|
|
527
569
|
if (nowPlaying) {
|
|
528
570
|
this.publishNowPlaying(nowPlaying);
|
|
529
571
|
}
|
|
572
|
+
} catch (error) {
|
|
573
|
+
console.error('Failed to publish now-playing metadata:', error);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Stop the outgoing track now rather than when Shaka eventually gets a URL.
|
|
578
|
+
// Announcing the new track while the old one is still audible is worse than
|
|
579
|
+
// the silence, and native has no such window — prepare() cuts the old item
|
|
580
|
+
// at the moment it announces. This adds no teardown: `player.load()`
|
|
581
|
+
// already unloads first (shaka player.js:1757-1762). It only moves when.
|
|
582
|
+
// Verified in a real browser to emit no `pause`, so no state comes of it.
|
|
583
|
+
this.requirePlayer().unload().catch(error => console.error('Failed to unload the previous track:', error));
|
|
584
|
+
|
|
585
|
+
// Loading covers the resolution too. Shaka dispatches its own `loading`
|
|
586
|
+
// once `player.load()` is called — after resolution — and the state setter
|
|
587
|
+
// drops that as a no-op. The `buffering` that follows is Shaka's to report.
|
|
588
|
+
this.dispatch({
|
|
589
|
+
type: 'trackLoading'
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
load(track, callback) {
|
|
593
|
+
// Before the queue is touched, so a load before `setupPlayer()` throws
|
|
594
|
+
// rather than half-applying.
|
|
595
|
+
this.requireElement();
|
|
530
596
|
|
|
597
|
+
// Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
|
|
598
|
+
this.nowPlayingManager.clearNowPlayingOverride();
|
|
599
|
+
this.syncQueueTo(track);
|
|
600
|
+
|
|
601
|
+
// Begun here, at the call, so a newer load supersedes this one immediately
|
|
602
|
+
// rather than once its own URL has resolved. Both continuations below carry
|
|
603
|
+
// it, as does `super.load()`.
|
|
604
|
+
const attempt = this.beginLoadAttempt();
|
|
605
|
+
|
|
606
|
+
// Loading ends being stopped. `dispatch()` drops every event while
|
|
607
|
+
// `_isStopped`, to keep Shaka's teardown noise off the stopped state, so
|
|
608
|
+
// the `trackLoading` in `transitionTo` would be swallowed on the stop → play
|
|
609
|
+
// path and the resolution window would report nothing. `super.load()` clears
|
|
610
|
+
// this too, but only after the URL resolves.
|
|
611
|
+
this._isStopped = false;
|
|
612
|
+
|
|
613
|
+
// Set loading flag early so seekTo() calls during async URL resolution
|
|
614
|
+
// are captured as pending seeks rather than silently dropped
|
|
615
|
+
this._loadInProgress = true;
|
|
616
|
+
this._pendingSeek = undefined;
|
|
617
|
+
this.transitionTo(track);
|
|
618
|
+
|
|
619
|
+
// Resolve the media URL before loading (async but we don't await)
|
|
620
|
+
const doLoad = async () => {
|
|
621
|
+
const resolvedRequest = track.src ? await this.resolveMediaRequest(track.src, track) : undefined;
|
|
622
|
+
// Web-only, and forced: a progressive file plays via `mediaElement.src`,
|
|
623
|
+
// which cannot carry headers. Native sends them with the media request
|
|
624
|
+
// and follows the redirect inline, so it needs none of this.
|
|
625
|
+
const resolvedMedia = resolvedRequest ? await followMediaRedirect(resolvedRequest.path ?? track.src, resolvedRequest.headers, {
|
|
626
|
+
mediaLayerHeaders: resolvedRequest.mediaLayerHeaders
|
|
627
|
+
}) : undefined;
|
|
628
|
+
|
|
629
|
+
// Something took over while the URL was resolving — discard this result
|
|
630
|
+
if (!attempt.isCurrent) return;
|
|
631
|
+
super.load(track, loadedTrack => {
|
|
531
632
|
// Call the provided callback if any
|
|
532
633
|
if (callback) {
|
|
533
634
|
callback(loadedTrack);
|
|
534
635
|
}
|
|
636
|
+
}, {
|
|
637
|
+
headers: resolvedMedia?.headers,
|
|
638
|
+
src: resolvedMedia?.src,
|
|
639
|
+
attempt
|
|
535
640
|
});
|
|
536
641
|
};
|
|
537
642
|
|
|
538
643
|
// Execute async load without blocking, with error handling
|
|
539
644
|
doLoad().catch(error => {
|
|
540
|
-
//
|
|
541
|
-
|
|
645
|
+
// A backstop with no reachable trigger today, kept because an unhandled
|
|
646
|
+
// rejection would be worse than a dead branch. Every await inside
|
|
647
|
+
// `doLoad` catches internally; `shaka.Player.load` is `async`, so it
|
|
648
|
+
// rejects rather than throwing and its rejection is handled against the
|
|
649
|
+
// attempt inside `Player.load`; and `this.player` is only ever assigned,
|
|
650
|
+
// so `requirePlayer()` cannot start succeeding and then fail mid-load.
|
|
651
|
+
//
|
|
652
|
+
// No attempt check either, for the same reason it would be dead: nothing
|
|
653
|
+
// awaits between the guard above and `super.load()`, so the attempt
|
|
654
|
+
// cannot have changed. Add an await there and this needs the check back.
|
|
542
655
|
this._loadInProgress = false;
|
|
543
656
|
this._pendingSeek = undefined;
|
|
544
657
|
console.error('Error loading track:', error);
|
|
@@ -701,10 +814,8 @@ export class NativeAudioBrowser extends QueuePlayer {
|
|
|
701
814
|
this.clearSleepTimerIfFading();
|
|
702
815
|
}
|
|
703
816
|
stop() {
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
// and revives the state out of Stopped.
|
|
707
|
-
this.currentLoadId++;
|
|
817
|
+
// `Player.stop()` cancels the load attempt, so an in-flight load's
|
|
818
|
+
// continuation cannot re-arm the player and revive it out of Stopped.
|
|
708
819
|
super.stop();
|
|
709
820
|
this.clearSleepTimerIfFading();
|
|
710
821
|
}
|
|
@@ -730,11 +841,13 @@ export class NativeAudioBrowser extends QueuePlayer {
|
|
|
730
841
|
this.sleepTimer.clear();
|
|
731
842
|
this.pause();
|
|
732
843
|
this.onSleepTimerChanged(null);
|
|
733
|
-
|
|
844
|
+
// Nothing is loading, so the caller must still report the end — the
|
|
845
|
+
// element has stopped and its own `pause` is swallowed as end-of-track.
|
|
846
|
+
return false;
|
|
734
847
|
}
|
|
735
848
|
|
|
736
849
|
// Otherwise proceed with normal track end behavior
|
|
737
|
-
super.onTrackEnded();
|
|
850
|
+
return super.onTrackEnded();
|
|
738
851
|
}
|
|
739
852
|
|
|
740
853
|
// MARK: Queue management
|