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
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Resolves a media URL that is authenticated by a header.
3
+ *
4
+ * Shaka only routes manifest requests through its networking engine, where a
5
+ * request filter can attach headers. A progressive file is played by assigning
6
+ * `mediaElement.src`, and a media element cannot send custom headers — so such
7
+ * a URL is unplayable that way. Following the redirect here yields the signed,
8
+ * public URL these endpoints hand back, which needs no auth of its own.
9
+ *
10
+ * Best effort and bounded: anything that fails — including taking too long, see
11
+ * {@link PROBE_TIMEOUT_MS} — falls back to the original URL, so the load fails
12
+ * or succeeds on its own terms rather than ours. It must never be the reason a
13
+ * load hangs: `load()` awaits this before Shaka sees the URL at all.
14
+ *
15
+ * Gated, because the probe is not free: a HEAD at a third-party stream host
16
+ * costs a CORS preflight, and most will refuse it, so an app that never
17
+ * authenticates its media would pay a round trip on every play for a result it
18
+ * always discards. It runs only for headers that look like media credentials
19
+ * ({@link isMediaAuth}), on media a media element actually has to play
20
+ * ({@link isManifestUrl}), and never on a URL the browser resolves locally.
21
+ */
22
+
23
+ /** URLs the browser resolves locally; there is no redirect to follow. */
24
+ const isLocalUrl = (url: string): boolean =>
25
+ /^(file|blob|data|mediasource):/i.test(url)
26
+
27
+ /**
28
+ * Extensions Shaka types as a manifest, from its own `EXTENSIONS_TO_MIME_TYPES_`
29
+ * map: HLS, DASH and Smooth Streaming.
30
+ */
31
+ const MANIFEST_EXTENSIONS = new Set(['m3u8', 'mpd', 'ism'])
32
+
33
+ /**
34
+ * Whether Shaka will parse this as a manifest rather than hand it to the media
35
+ * element — in which case the probe has nothing to offer and something to cost.
36
+ *
37
+ * A manifest goes through Shaka's networking engine, where the request filter
38
+ * attaches the headers to the manifest, its segments and any key requests. The
39
+ * probe cannot improve on that, and it actively breaks it: on a cross-origin
40
+ * redirect the probe clears the headers, so the segments that Android still
41
+ * authenticates would go out bare and 401. So this is a correctness fix as much
42
+ * as it is one fewer round trip.
43
+ *
44
+ * Extension-based, and therefore partial — Shaka guesses the same way
45
+ * (`NetworkingUtils.getExtension`), but plenty of HLS endpoints have no
46
+ * extension at all. Those still get probed, which is the status quo rather than
47
+ * a regression. Nothing on `Track` carries a MIME type to do better with.
48
+ */
49
+ const isManifestUrl = (url: string): boolean => {
50
+ const path = url.split(/[?#]/)[0] ?? ''
51
+ const filename = path.slice(path.lastIndexOf('/') + 1)
52
+ const pieces = filename.split('.')
53
+ return (
54
+ pieces.length > 1 &&
55
+ MANIFEST_EXTENSIONS.has(pieces[pieces.length - 1]!.toLowerCase())
56
+ )
57
+ }
58
+
59
+ export interface FollowMediaRedirectOptions {
60
+ /** Injected for testing; defaults to the global `fetch`. */
61
+ fetchImpl?: typeof fetch
62
+ /**
63
+ * Whether the `media` layer contributed any of these headers, rather than
64
+ * their all arriving from the shared `request` layer.
65
+ */
66
+ mediaLayerHeaders?: boolean
67
+ /** Milliseconds before the probe gives up. Defaults to {@link PROBE_TIMEOUT_MS}. */
68
+ timeoutMs?: number
69
+ }
70
+
71
+ /**
72
+ * How long the probe may take before the original URL is played instead.
73
+ *
74
+ * The same 8s the Android media client allows a connect or a read
75
+ * (`MediaFactory.mediaHttpClient`), which is generous for a HEAD but errs the
76
+ * safe way: a slow-but-working auth endpoint should not be abandoned, since
77
+ * giving up means playing a URL that will 401.
78
+ */
79
+ const PROBE_TIMEOUT_MS = 8_000
80
+
81
+ /**
82
+ * `AbortSignal.timeout` where it exists (Safari 16+, Chrome 103+, Node 17.3+),
83
+ * else a hand-rolled equivalent, else nothing — an environment with neither
84
+ * still gets the deadline below, just without cancelling the request.
85
+ */
86
+ const timeoutSignal = (ms: number): AbortSignal | undefined => {
87
+ if (typeof AbortSignal === 'undefined') return undefined
88
+ if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms)
89
+ if (typeof AbortController === 'undefined') return undefined
90
+ const controller = new AbortController()
91
+ setTimeout(() => controller.abort(), ms)
92
+ return controller.signal
93
+ }
94
+
95
+ /**
96
+ * The probe, with a deadline. `load()` awaits this before Shaka is asked to
97
+ * play anything, so an unreachable host would otherwise stall the load
98
+ * indefinitely — no audio, and no error to show for it.
99
+ *
100
+ * Two mechanisms, each with its own job: the signal cancels the request, so a
101
+ * dead host does not hold a connection open behind us, and the race bounds *us*
102
+ * even where the fetch implementation ignores the signal. Whichever fires, the
103
+ * caller treats it as any other probe failure and plays the original URL.
104
+ */
105
+ const fetchWithDeadline = async (
106
+ doFetch: typeof fetch,
107
+ url: string,
108
+ headers: Record<string, string>,
109
+ timeoutMs: number
110
+ ): Promise<Response> => {
111
+ let timer: ReturnType<typeof setTimeout> | undefined
112
+ try {
113
+ return await Promise.race([
114
+ doFetch(url, {
115
+ method: 'HEAD',
116
+ headers,
117
+ redirect: 'follow',
118
+ signal: timeoutSignal(timeoutMs)
119
+ }),
120
+ new Promise<never>((_resolve, reject) => {
121
+ timer = setTimeout(
122
+ () =>
123
+ reject(
124
+ new Error(`Media redirect probe timed out (${timeoutMs}ms)`)
125
+ ),
126
+ timeoutMs
127
+ )
128
+ })
129
+ ])
130
+ } finally {
131
+ if (timer !== undefined) clearTimeout(timer)
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Whether these headers look like a media credential — the only thing the
137
+ * probe can fix, and the only thing worth a preflight.
138
+ *
139
+ * A header the `media` layer set is media-specific by construction. Otherwise
140
+ * only `Authorization` counts: an app authenticating one API for browse and
141
+ * media alike puts it on the shared `request` layer, and that is the common
142
+ * shape. Shared plumbing that also rides browse requests — an `X-Client-Version`
143
+ * — is not a credential and must not trigger the probe.
144
+ *
145
+ * The residual case is a shared layer carrying a custom auth header, say
146
+ * `X-Api-Key`, for media that needs it: move it to the `media` layer and the
147
+ * probe runs.
148
+ */
149
+ const isMediaAuth = (
150
+ headers: Record<string, string>,
151
+ mediaLayerHeaders: boolean
152
+ ): boolean =>
153
+ mediaLayerHeaders ||
154
+ Object.keys(headers).some((key) => key.toLowerCase() === 'authorization')
155
+
156
+ export interface ResolvedMedia {
157
+ /** The URL to play. */
158
+ src: string
159
+ /**
160
+ * The headers still safe to send. Cleared once the redirect leaves the
161
+ * origin: the credential was addressed to the authenticating host, and
162
+ * browsers strip `Authorization` on that hop for the same reason.
163
+ *
164
+ * Only for what we send *after* the probe. The probe's own hop is the
165
+ * browser's to make: `redirect: 'follow'` drops `Authorization` across
166
+ * origins but re-sends every other header, so a custom-named credential can
167
+ * reach the redirect target if that target's preflight admits it. There is no
168
+ * way around it from JS — `redirect: 'manual'` yields an opaque response in
169
+ * CORS mode, with no `Location` to walk by hand — so a custom-named media
170
+ * credential is only as private as the hosts it may be redirected to.
171
+ * Android has no such limit: [CrossOriginHeaderStripper] strips the hop
172
+ * there.
173
+ */
174
+ headers?: Record<string, string>
175
+ }
176
+
177
+ /**
178
+ * Origin of an http(s) URL, or undefined when it cannot be determined.
179
+ *
180
+ * Relative URLs are resolved against the document, because that is what the
181
+ * probe's own `fetch` does — a `src` of `/api/authed/7` is a request to the
182
+ * page's origin, so the credential went there. Without this a relative `src`
183
+ * has no origin, never matches the absolute URL the response reports, and the
184
+ * headers are dropped from every load that uses one.
185
+ *
186
+ * Parsed rather than pattern-matched so the comparison normalizes the way
187
+ * Android's `CrossOriginHeaderStripper.sameOrigin` does: `URL.host` omits a
188
+ * default port, so `https://a.com` and `https://a.com:443` are one origin, and
189
+ * it excludes userinfo, so `https://user@a.com` is not a third.
190
+ */
191
+ const originOf = (url: string): string | undefined => {
192
+ try {
193
+ const base = typeof location === 'undefined' ? undefined : location.href
194
+ const { protocol, host } = new URL(url, base)
195
+ if (protocol !== 'http:' && protocol !== 'https:') return undefined
196
+ return `${protocol}//${host}`
197
+ } catch {
198
+ return undefined
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Whether the redirect left the origin the credential was addressed to.
204
+ *
205
+ * Only when both origins are known and differ. An origin we could not work out
206
+ * — a relative URL with no document base, outside a browser — is not evidence
207
+ * of a hop, and treating it as one is how the headers went missing before.
208
+ */
209
+ const leftTheOrigin = (from: string, to: string): boolean => {
210
+ const origin = originOf(from)
211
+ return origin !== undefined && originOf(to) !== origin
212
+ }
213
+
214
+ export async function followMediaRedirect(
215
+ url: string,
216
+ headers: Record<string, string> | undefined,
217
+ {
218
+ fetchImpl,
219
+ mediaLayerHeaders = false,
220
+ timeoutMs = PROBE_TIMEOUT_MS
221
+ }: FollowMediaRedirectOptions = {}
222
+ ): Promise<ResolvedMedia> {
223
+ if (!headers || Object.keys(headers).length === 0)
224
+ return { src: url, headers }
225
+ if (!isMediaAuth(headers, mediaLayerHeaders)) return { src: url, headers }
226
+ if (isManifestUrl(url)) return { src: url, headers }
227
+ if (isLocalUrl(url)) return { src: url, headers }
228
+
229
+ const doFetch = fetchImpl ?? globalThis.fetch
230
+ if (typeof doFetch !== 'function') return { src: url, headers }
231
+
232
+ try {
233
+ // HEAD walks the chain without tripping download counting, which keys off
234
+ // ranged GETs. Deadlined, because the load waits on it.
235
+ const response = await fetchWithDeadline(doFetch, url, headers, timeoutMs)
236
+
237
+ const src = response.url || url
238
+
239
+ // The status belongs to the *last* hop, so it only speaks for the
240
+ // authenticating endpoint when there was no redirect. Once one has
241
+ // happened, hop one authenticated and handed us its target, and a refusal
242
+ // past that is routinely about the method rather than the credential:
243
+ // presigned S3 and GCS URLs sign it — SigV4's canonical request opens with
244
+ // HTTPMethod, GCS V4's with HTTP_VERB — so a URL signed for GET answers a
245
+ // HEAD with 403. Keep the signed URL, which is the whole point of the walk,
246
+ // and let a real error surface through the player.
247
+ if (!response.ok && src === url) return { src: url, headers }
248
+
249
+ return { src, headers: leftTheOrigin(url, src) ? undefined : headers }
250
+ } catch {
251
+ return { src: url, headers }
252
+ }
253
+ }