@wwdrew/expo-spotify-sdk 0.7.1 → 1.0.0

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 (85) hide show
  1. package/README.md +388 -108
  2. package/android/build.gradle +10 -0
  3. package/android/consumer-rules.pro +5 -0
  4. package/android/libs/SETUP.md +29 -0
  5. package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +149 -12
  6. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +556 -0
  7. package/android/src/main/java/expo/modules/spotifysdk/SpotifyConfig.kt +1 -0
  8. package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +112 -0
  9. package/build/ExpoSpotifySDK.types.d.ts +12 -0
  10. package/build/ExpoSpotifySDK.types.d.ts.map +1 -1
  11. package/build/ExpoSpotifySDK.types.js.map +1 -1
  12. package/build/ExpoSpotifySDKModule.web.d.ts +23 -0
  13. package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
  14. package/build/ExpoSpotifySDKModule.web.js +74 -5
  15. package/build/ExpoSpotifySDKModule.web.js.map +1 -1
  16. package/build/app-remote/error.d.ts +8 -0
  17. package/build/app-remote/error.d.ts.map +1 -0
  18. package/build/app-remote/error.js +10 -0
  19. package/build/app-remote/error.js.map +1 -0
  20. package/build/app-remote/index.d.ts +79 -0
  21. package/build/app-remote/index.d.ts.map +1 -0
  22. package/build/app-remote/index.js +110 -0
  23. package/build/app-remote/index.js.map +1 -0
  24. package/build/auth/error.d.ts +8 -0
  25. package/build/auth/error.d.ts.map +1 -0
  26. package/build/auth/error.js +10 -0
  27. package/build/auth/error.js.map +1 -0
  28. package/build/auth/index.d.ts +124 -0
  29. package/build/auth/index.d.ts.map +1 -0
  30. package/build/auth/index.js +162 -0
  31. package/build/auth/index.js.map +1 -0
  32. package/build/content/error.d.ts +8 -0
  33. package/build/content/error.d.ts.map +1 -0
  34. package/build/content/error.js +10 -0
  35. package/build/content/error.js.map +1 -0
  36. package/build/content/index.d.ts +38 -0
  37. package/build/content/index.d.ts.map +1 -0
  38. package/build/content/index.js +56 -0
  39. package/build/content/index.js.map +1 -0
  40. package/build/error.d.ts +24 -0
  41. package/build/error.d.ts.map +1 -0
  42. package/build/error.js +25 -0
  43. package/build/error.js.map +1 -0
  44. package/build/hooks/index.d.ts +90 -0
  45. package/build/hooks/index.d.ts.map +1 -0
  46. package/build/hooks/index.js +326 -0
  47. package/build/hooks/index.js.map +1 -0
  48. package/build/images/error.d.ts +8 -0
  49. package/build/images/error.d.ts.map +1 -0
  50. package/build/images/error.js +10 -0
  51. package/build/images/error.js.map +1 -0
  52. package/build/images/index.d.ts +36 -0
  53. package/build/images/index.d.ts.map +1 -0
  54. package/build/images/index.js +59 -0
  55. package/build/images/index.js.map +1 -0
  56. package/build/index.d.ts +44 -42
  57. package/build/index.d.ts.map +1 -1
  58. package/build/index.js +49 -122
  59. package/build/index.js.map +1 -1
  60. package/build/player/error.d.ts +8 -0
  61. package/build/player/error.d.ts.map +1 -0
  62. package/build/player/error.js +10 -0
  63. package/build/player/error.js.map +1 -0
  64. package/build/player/index.d.ts +140 -0
  65. package/build/player/index.d.ts.map +1 -0
  66. package/build/player/index.js +136 -0
  67. package/build/player/index.js.map +1 -0
  68. package/build/uri/index.d.ts +55 -0
  69. package/build/uri/index.d.ts.map +1 -0
  70. package/build/uri/index.js +57 -0
  71. package/build/uri/index.js.map +1 -0
  72. package/build/user/error.d.ts +8 -0
  73. package/build/user/error.d.ts.map +1 -0
  74. package/build/user/error.js +10 -0
  75. package/build/user/error.js.map +1 -0
  76. package/build/user/index.d.ts +44 -0
  77. package/build/user/index.d.ts.map +1 -0
  78. package/build/user/index.js +97 -0
  79. package/build/user/index.js.map +1 -0
  80. package/ios/ExpoSpotifySDKModule.swift +233 -6
  81. package/ios/SPTScopeSerializer.swift +5 -0
  82. package/ios/SpotifyAppRemoteCoordinator.swift +939 -0
  83. package/ios/SpotifyAuthCoordinator.swift +67 -13
  84. package/ios/SpotifyTokenRefreshClient.swift +32 -1
  85. package/package.json +4 -3
package/README.md CHANGED
@@ -4,20 +4,61 @@
4
4
  [![CI](https://img.shields.io/github/actions/workflow/status/wwdrew/expo-spotify-sdk/ci.yml?label=CI)](https://github.com/wwdrew/expo-spotify-sdk/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/github/license/wwdrew/expo-spotify-sdk)](LICENSE)
6
6
 
7
- An Expo module that wraps the native [Spotify iOS SDK](https://github.com/spotify/ios-sdk) (v5.0.1) and [Spotify Android SDK](https://github.com/spotify/android-sdk) (v4.0.1) to provide OAuth authentication in Expo and React Native apps.
7
+ An Expo module that wraps the native [Spotify iOS SDK](https://github.com/spotify/ios-sdk) (v5.0.1) and [Spotify Android SDK](https://github.com/spotify/android-sdk) (v4.0.1) to provide Spotify Auth + App Remote control in Expo and React Native apps.
8
8
 
9
9
  **Why this exists:** Spotify ships native SDKs for iOS and Android that enable authentication via the installed Spotify app (no browser redirect, better UX) but there is no maintained Expo module for them. This library fills that gap.
10
10
 
11
11
  ## Platform support
12
12
 
13
- | Feature | iOS | Android | Web |
14
- | --------------------------------------------- | --- | ------------ | ------------------- |
15
- | `isAvailable()` | | | (always `false`) |
16
- | `authenticateAsync` CODE flow (recommended) | ✅ | | — |
17
- | `authenticateAsync` TOKEN flow (implicit) | ✅ | ⚠️ see below | — |
18
- | `refreshSessionAsync` | ✅ | ✅ | |
19
- | Auth via installed Spotify app | ✅ | ✅ | |
20
- | Auth via Spotify web fallback | ✅ | ✅ | |
13
+ **iOS and Android only.** This module will not support Expo Web or any browser target — there is no web implementation and none is planned.
14
+
15
+ | Feature | iOS | Android |
16
+ | --- | --- | --- |
17
+ | `Auth.*` | | ✅ |
18
+ | `AppRemote.*` | ✅ | ✅ |
19
+ | `Player.*` | ✅ | ✅ |
20
+ | `User.*` | ✅ | ✅ |
21
+ | `Content.*` | ✅ | ✅ |
22
+ | `Images.*` | ✅ | ✅ |
23
+
24
+ ## Versioning and Expo SDK lanes
25
+
26
+ Install the major that matches your Expo SDK:
27
+
28
+ | npm version | Expo SDK | iOS minimum | Branch |
29
+ | --- | --- | --- | --- |
30
+ | **`1.x`** | 55 | 15.1 | `v1` (long-lived maintenance) |
31
+ | **`2.x`** | 56+ | 16.4 | `main` |
32
+
33
+ Both lanes ship the same public API (Auth + App Remote namespaces and hooks). The major version signals **runtime lane**, not a different feature set. See [ADR-0005](./docs/adr/0005-sdk-lane-versioning.md).
34
+
35
+ The current `main` branch targets **Expo SDK 55** and releases as **`1.0.0`**. SDK 56 migration is [Phase 7](./docs/V1_PLAN.md#phase-7--migrate-main-to-expo-sdk-56-v200) (`2.0.0` on `main`).
36
+
37
+ ## v1 namespaced API
38
+
39
+ ```ts
40
+ import {
41
+ Auth,
42
+ AppRemote,
43
+ Player,
44
+ User,
45
+ Content,
46
+ Images,
47
+ SpotifyURI,
48
+ useSession,
49
+ useConnectionState,
50
+ usePlayerState,
51
+ useCurrentTrack,
52
+ useIsPlaying,
53
+ usePlaybackPosition,
54
+ useCapabilities,
55
+ useLibraryState,
56
+ } from "@wwdrew/expo-spotify-sdk";
57
+ ```
58
+
59
+ Top-level v0-style functions (`authenticateAsync`, `isAvailable`, etc.) are still exported for backward compatibility but are deprecated — see [Migration from v0.x](#migration-from-v0x).
60
+
61
+ **Not wrapped:** [Spotify Web API](https://developer.spotify.com/documentation/web-api) (`api.spotify.com`). Use the access token from `Auth.authenticate()` and call REST yourself. See [CONTEXT.md](./CONTEXT.md) for terminology.
21
62
 
22
63
  ## Quick start (Expo)
23
64
 
@@ -137,164 +178,379 @@ The redirect URI registered in your [Spotify Developer Dashboard](https://develo
137
178
 
138
179
  ## Usage
139
180
 
181
+ Typical integration: authenticate, connect App Remote, then read/control playback via hooks.
182
+
140
183
  ```ts
184
+ import { useEffect } from "react";
141
185
  import {
142
- isAvailable,
143
- authenticateAsync,
144
- refreshSessionAsync,
145
- addSessionChangeListener,
146
- SpotifyError,
186
+ Auth,
187
+ AppRemote,
188
+ AuthError,
189
+ AppRemoteError,
190
+ useSession,
191
+ useConnectionState,
192
+ useCurrentTrack,
193
+ useIsPlaying,
147
194
  } from "@wwdrew/expo-spotify-sdk";
148
195
 
149
- // Check whether the Spotify app is installed
150
- const spotifyInstalled = isAvailable();
196
+ async function login() {
197
+ if (!Auth.isAvailable()) {
198
+ throw new Error("Install the Spotify app to continue");
199
+ }
151
200
 
152
- // Authenticate
153
- try {
154
- const session = await authenticateAsync({
155
- scopes: ["user-read-email", "streaming"],
201
+ // On iOS, clear a leaked in-flight auth before retrying.
202
+ await Auth.cancelPending();
203
+
204
+ return Auth.authenticate({
205
+ scopes: ["app-remote-control", "user-read-playback-state", "streaming"],
156
206
  tokenSwapURL: "https://your-server.example.com/swap",
157
207
  tokenRefreshURL: "https://your-server.example.com/refresh",
158
208
  });
209
+ }
159
210
 
160
- console.log(session.accessToken); // use with Spotify Web API
161
- console.log(session.refreshToken); // store securely for later refresh
162
- console.log(session.expirationDate); // Unix epoch milliseconds
163
- console.log(session.scopes); // granted scopes
164
- } catch (e) {
165
- if (e instanceof SpotifyError) {
166
- if (e.code === "USER_CANCELLED") return; // user backed out — benign
167
- console.error(`[${e.code}] ${e.message}`);
211
+ async function connectRemote(accessToken: string) {
212
+ try {
213
+ await AppRemote.connect(accessToken);
214
+ } catch (e) {
215
+ if (e instanceof AppRemoteError && e.code === "CONNECTION_FAILED") {
216
+ // Spotify's IPC transport is not ready — open Spotify and retry.
217
+ // See "App Remote connection failed" in Troubleshooting.
218
+ }
219
+ throw e;
168
220
  }
169
221
  }
222
+
223
+ function NowPlaying() {
224
+ const session = useSession();
225
+ const connectionState = useConnectionState();
226
+ const track = useCurrentTrack();
227
+ const isPlaying = useIsPlaying();
228
+
229
+ useEffect(() => {
230
+ if (session == null || connectionState !== "disconnected") return;
231
+ void connectRemote(session.accessToken);
232
+ }, [session, connectionState]);
233
+
234
+ if (connectionState !== "connected") {
235
+ return null;
236
+ }
237
+
238
+ return (
239
+ <Text>
240
+ {track?.name ?? "No track"} · {isPlaying ? "Playing" : "Paused"}
241
+ </Text>
242
+ );
243
+ }
170
244
  ```
171
245
 
172
- ### Refresh a session
246
+ Omit `tokenSwapURL` / `tokenRefreshURL` to use the implicit TOKEN flow (iOS only for refresh; not recommended on Android — see below). For local development without a swap server, auth can still succeed on iOS; production apps should use the code + swap flow.
173
247
 
174
- ```ts
175
- import { refreshSessionAsync } from "@wwdrew/expo-spotify-sdk";
248
+ ### Check account tier (Web API)
176
249
 
177
- const refreshed = await refreshSessionAsync({
178
- refreshToken: storedRefreshToken,
179
- tokenRefreshURL: "https://your-server.example.com/refresh",
250
+ App Remote does not expose Premium status. Call Spotify's Web API with the access token from `Auth.authenticate()`:
251
+
252
+ ```ts
253
+ const res = await fetch("https://api.spotify.com/v1/me", {
254
+ headers: { Authorization: `Bearer ${session.accessToken}` },
180
255
  });
256
+ const profile = await res.json();
257
+ const isPremium = profile.product === "premium";
181
258
  ```
182
259
 
183
- ## API reference
260
+ The [example app](./example) displays this as "Account tier: Premium / Free".
184
261
 
185
- ### `isAvailable(): boolean`
262
+ ### Auto-connect App Remote
186
263
 
187
- Returns `true` if the Spotify app is installed on the device. Always returns `false` on web. Does not throw.
264
+ Avoid reconnect loops by gating on connection state and attempting at most once per session restore:
188
265
 
189
- ---
266
+ ```ts
267
+ import { useEffect, useRef } from "react";
268
+ import { AppRemote, useConnectionState, useSession } from "@wwdrew/expo-spotify-sdk";
269
+
270
+ /**
271
+ * Connect when a session exists and App Remote is disconnected.
272
+ * Set `once` to true to avoid retry spam after failures (recommended on cold start).
273
+ */
274
+ export function useAutoConnectAppRemote(options?: { once?: boolean }) {
275
+ const session = useSession();
276
+ const connectionState = useConnectionState();
277
+ const attemptedRef = useRef(false);
278
+
279
+ useEffect(() => {
280
+ const token = session?.accessToken;
281
+ if (!token || connectionState !== "disconnected") return;
282
+ if (options?.once && attemptedRef.current) return;
283
+
284
+ attemptedRef.current = true;
285
+ void AppRemote.connect(token).catch(() => {
286
+ // Surface via AppRemote.addListener("connectionError") or your UI.
287
+ });
288
+ }, [session?.accessToken, connectionState, options?.once]);
289
+ }
290
+ ```
190
291
 
191
- ### `authenticateAsync(config: SpotifyConfig): Promise<SpotifySession>`
292
+ For iOS, if `connect()` fails with `CONNECTION_FAILED`, foreground the Spotify app and retry manually — see [Troubleshooting](#troubleshooting).
192
293
 
193
- Starts a Spotify OAuth flow. If the Spotify app is installed it authenticates natively; otherwise it falls back to the Spotify web login page.
294
+ ## Spotify Premium and App Remote
194
295
 
195
- **Throws [`SpotifyError`](#spotifyerror)** on failure.
296
+ | Concern | Auth (`Auth.*`) | App Remote (`AppRemote.*`, `Player.*`, …) |
297
+ | --- | --- | --- |
298
+ | Spotify app installed | Recommended (native UX) | **Required** |
299
+ | Spotify app running | No | **Required** — connect talks to the running Spotify process over IPC |
300
+ | Spotify Premium | No | **Required** for reliable on-demand playback and rich player state (track name, transport, browse) |
301
+ | Free account | Auth works | `Player.play()` may fail with `PREMIUM_REQUIRED`; `User.getCapabilities().canPlayOnDemand` is `false` |
196
302
 
197
- **Parameters (`SpotifyConfig`):**
303
+ **This library does not play audio.** It remote-controls the official Spotify app. On Android especially, Free accounts often see empty or incomplete now-playing metadata even when connected.
198
304
 
199
- | Field | Type | Required | Description |
200
- | ----------------- | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
201
- | `scopes` | `SpotifyScope[]` | ✅ | OAuth scopes to request. Must contain at least one entry. |
202
- | `tokenSwapURL` | `string` | — | URL of your token swap server endpoint. Triggers CODE flow (recommended). Required on Android to receive a `refreshToken`. |
203
- | `tokenRefreshURL` | `string` | — | URL of your token refresh server endpoint. Used by iOS SDK natively and by `refreshSessionAsync` on both platforms. |
204
- | `showDialog` | `boolean` | — | Force Spotify to show the authorization dialog even when the user already has an active session. Defaults to `false`. Useful during development; avoid in production. |
305
+ ## Migration from v0.x
205
306
 
206
- **Returns (`SpotifySession`):**
307
+ v0.x top-level functions remain exported but are deprecated (removed in v2.0.0).
207
308
 
208
- | Field | Type | Description |
209
- | ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
210
- | `accessToken` | `string` | OAuth access token. Use as `Authorization: Bearer <token>` with the Spotify Web API. |
211
- | `refreshToken` | `string \| null` | Refresh token. `null` on Android when no `tokenSwapURL` is provided — see [Android implicit flow](#android-implicit-token-flow-is-not-recommended). |
212
- | `expirationDate` | `number` | Token expiry as Unix epoch **milliseconds**. |
213
- | `scopes` | `SpotifyScope[]` | Granted scopes. On Android TOKEN flow, reflects requested scopes (Spotify does not expose granted scopes in the implicit flow). |
309
+ | v0.x | v1 |
310
+ | --- | --- |
311
+ | `isAvailable()` | `Auth.isAvailable()` |
312
+ | `authenticateAsync(config)` | `Auth.authenticate(config)` |
313
+ | `refreshSessionAsync(config)` | `Auth.refresh(config)` |
314
+ | `cancelPendingAuthAsync()` | `Auth.cancelPending()` |
315
+ | `addSessionChangeListener(cb)` | `Auth.addListener("sessionChange", cb)` |
316
+ | `SpotifyError` (auth throws) | `AuthError` (or `instanceof SpotifyError` as catch-all) |
317
+ | `SpotifyErrorCode` | `AuthErrorCode` |
214
318
 
215
- ---
319
+ `SpotifySession`, `SpotifyScope`, and config shapes are unchanged. For auth-specific `e.code` narrowing, use `instanceof AuthError` instead of `instanceof SpotifyError`.
216
320
 
217
- ### `refreshSessionAsync(options): Promise<SpotifySession>`
321
+ ## API reference
218
322
 
219
- Exchanges a refresh token for a new access token via your token refresh server.
323
+ ### `Auth`
220
324
 
221
- **Parameters:**
325
+ #### `Auth.isAvailable(): boolean`
222
326
 
223
- | Field | Type | Description |
224
- | ----------------- | -------- | ----------------------------------------------------------- |
225
- | `refreshToken` | `string` | The refresh token from a previous `authenticateAsync` call. |
226
- | `tokenRefreshURL` | `string` | URL of your token refresh server endpoint. |
327
+ Returns `true` if the Spotify app is installed. Does not throw (not available on unsupported platforms).
227
328
 
228
- **Returns** a fresh `SpotifySession` with an updated `accessToken` and `expirationDate`. If the server rotates the refresh token the new one is returned in `refreshToken`; otherwise the original token is returned so you can continue refreshing.
329
+ #### `Auth.authenticate(config): Promise<SpotifySession>`
229
330
 
230
- ---
331
+ Starts OAuth via the installed Spotify app (or web fallback). Throws [`AuthError`](#autherror) on failure.
231
332
 
232
- ### `addSessionChangeListener(listener): Subscription`
333
+ | Field | Type | Required | Description |
334
+ | --- | --- | --- | --- |
335
+ | `scopes` | `SpotifyScope[]` | ✅ | At least one scope. Include `app-remote-control` for App Remote. |
336
+ | `tokenSwapURL` | `string` | — | Code flow + server swap (recommended). Required on Android for a `refreshToken`. |
337
+ | `tokenRefreshURL` | `string` | — | Refresh endpoint for iOS SDK and `Auth.refresh()`. |
338
+ | `showDialog` | `boolean` | — | Force the consent screen. Default `false`. |
233
339
 
234
- Subscribes to session lifecycle events emitted by the native module. Events fire for every `authenticateAsync` and `refreshSessionAsync` call — including ones you didn't directly `await` — making this the right place to persist tokens in a central store.
340
+ | Return field | Type | Description |
341
+ | --- | --- | --- |
342
+ | `accessToken` | `string` | Bearer token for Web API and `AppRemote.connect()`. |
343
+ | `refreshToken` | `string \| null` | `null` on Android without `tokenSwapURL`. |
344
+ | `expirationDate` | `number` | Expiry as Unix epoch **milliseconds**. |
345
+ | `scopes` | `SpotifyScope[]` | Granted scopes (requested-only on Android TOKEN flow). |
235
346
 
236
- Returns a `Subscription` object; call `.remove()` to unsubscribe.
347
+ #### `Auth.cancelPending(): Promise<void>`
237
348
 
238
- ```ts
239
- import { addSessionChangeListener } from "@wwdrew/expo-spotify-sdk";
240
-
241
- const sub = addSessionChangeListener((event) => {
242
- switch (event.type) {
243
- case "didInitiate":
244
- case "didRenew":
245
- store.setSession(event.session); // { accessToken, refreshToken, expirationDate, scopes }
246
- break;
247
- case "didFail":
248
- console.error(`[${event.error.code}] ${event.error.message}`);
249
- break;
250
- }
251
- });
349
+ Cancels an in-flight `Auth.authenticate()`. On iOS, call before retrying if you see `AUTH_IN_PROGRESS` after a dropped redirect. No-op on Android.
252
350
 
253
- // When the subscribing component unmounts:
254
- sub.remove();
351
+ ```ts
352
+ await Auth.cancelPending();
353
+ const session = await Auth.authenticate({ scopes: ["streaming"] });
255
354
  ```
256
355
 
257
- **Event types (`SpotifySessionChangeEvent`):**
356
+ A cancelled call rejects with `AuthError` `code: "USER_CANCELLED"`.
357
+
358
+ #### `Auth.refresh(config): Promise<SpotifySession>`
359
+
360
+ Exchanges a refresh token via your refresh server. Pass through `scopes` from the previous session when the refresh response omits `scope`.
361
+
362
+ ```ts
363
+ const refreshed = await Auth.refresh({
364
+ refreshToken: previous.refreshToken!,
365
+ tokenRefreshURL: "https://your-server.example.com/refresh",
366
+ scopes: previous.scopes,
367
+ });
368
+ ```
258
369
 
259
- | `type` | Payload | Fired when |
260
- |---|---|---|
261
- | `"didInitiate"` | `{ session: SpotifySession }` | `authenticateAsync` succeeded |
262
- | `"didRenew"` | `{ session: SpotifySession }` | `refreshSessionAsync` succeeded |
263
- | `"didFail"` | `{ error: { code, message } }` | Either function rejected |
370
+ #### `Auth.addListener("sessionChange", listener): Subscription`
264
371
 
265
- ---
372
+ Central place to persist tokens. Fires for every `authenticate` / `refresh` — including calls you did not `await`.
266
373
 
267
- ### `SpotifyError`
374
+ | `type` | Payload | When |
375
+ | --- | --- | --- |
376
+ | `"didInitiate"` | `{ session }` | `Auth.authenticate()` succeeded |
377
+ | `"didRenew"` | `{ session }` | `Auth.refresh()` succeeded |
378
+ | `"didFail"` | `{ error: { code, message } }` | Either call failed |
268
379
 
269
- All rejections from `authenticateAsync` and `refreshSessionAsync` are instances of `SpotifyError`:
380
+ #### `AuthError`
270
381
 
271
382
  ```ts
272
- import { SpotifyError } from "@wwdrew/expo-spotify-sdk";
383
+ import { Auth, AuthError } from "@wwdrew/expo-spotify-sdk";
273
384
 
274
385
  try {
275
- await authenticateAsync({ scopes: ["streaming"] });
386
+ await Auth.authenticate({ scopes: ["streaming"] });
276
387
  } catch (e) {
277
- if (e instanceof SpotifyError) {
388
+ if (e instanceof AuthError) {
278
389
  switch (e.code) {
279
- case "USER_CANCELLED": // user closed the auth screen — benign
280
- case "AUTH_IN_PROGRESS": // concurrent call — benign
390
+ case "USER_CANCELLED":
391
+ case "AUTH_IN_PROGRESS":
281
392
  return;
282
- case "INVALID_CONFIG": // missing clientID / scopes / tokenSwapURL
283
- case "NETWORK_ERROR": // connectivity failure during token swap
284
- case "TOKEN_SWAP_FAILED": // swap server returned non-2xx (e.message has status + body)
285
- case "TOKEN_SWAP_PARSE_ERROR": // swap server returned invalid JSON
286
- case "SPOTIFY_NOT_INSTALLED": // Spotify app not found (rare — most flows fall back to web)
287
- case "AUTH_ERROR": // Spotify returned an error (e.message has detail)
288
- case "UNKNOWN": // unexpected failure
393
+ case "INVALID_CONFIG":
394
+ case "NETWORK_ERROR":
395
+ case "TOKEN_SWAP_FAILED":
396
+ case "TOKEN_SWAP_PARSE_ERROR":
397
+ case "SPOTIFY_NOT_INSTALLED":
398
+ case "AUTH_ERROR":
399
+ case "UNKNOWN":
289
400
  reportError(e);
290
401
  }
291
402
  }
292
403
  }
293
404
  ```
294
405
 
406
+ `e.message` is the native reason string. On iOS `UNKNOWN` auth failures, the message includes the full `NSError` chain (e.g. `NSURLErrorDomain` to your `tokenSwapURL`). `e.cause` retains the original error for logging.
407
+
408
+ ### `AppRemote`
409
+
410
+ Connects to the **running** Spotify app. All `Player`, `User`, `Content`, and `Images` calls require an active connection.
411
+
412
+ | Method | Description |
413
+ | --- | --- |
414
+ | `connect(accessToken)` | Open IPC to Spotify. Resolves when connected. |
415
+ | `disconnect()` | Tear down connection. |
416
+ | `isConnected()` | Synchronous snapshot. |
417
+ | `getConnectionState()` | `"disconnected"` \| `"connecting"` \| `"connected"`. |
418
+ | `addListener("connectionStateChange", cb)` | State transitions. |
419
+ | `addListener("connectionError", cb)` | Failures and drops (`AppRemoteError` codes). |
420
+
421
+ **Platform notes:**
422
+
423
+ - **iOS:** pass the access token from `Auth.authenticate()`. If connect fails with `CONNECTION_FAILED` / `Connection refused` (`NSPOSIXErrorDomain` 61), bring Spotify to the foreground and retry — backgrounded Spotify often has no transport listener yet.
424
+ - **Android:** `accessToken` is accepted for API parity; the SDK uses the session cached in the Spotify app from your prior `Auth.authenticate()` call.
425
+
426
+ Calling `connect()` while already connected is a no-op.
427
+
428
+ ### `Player`
429
+
430
+ - `Player.play(uri)`
431
+ - `Player.pause()`
432
+ - `Player.resume()`
433
+ - `Player.skipNext()`
434
+ - `Player.skipPrevious()`
435
+ - `Player.seekTo(positionMs)`
436
+ - `Player.setShuffle(enabled)`
437
+ - `Player.setRepeatMode(mode)`
438
+ - `Player.setPodcastPlaybackSpeed(speed)`
439
+ - `Player.queue(uri)`
440
+ - `Player.getPlayerState()`
441
+ - `Player.getCrossfadeState()`
442
+ - `Player.addListener("playerStateChange", cb)`
443
+
444
+ ### `User`
445
+
446
+ - `User.getCapabilities()`
447
+ - `User.getLibraryState(uri)`
448
+ - `User.addToLibrary(uri)`
449
+ - `User.removeFromLibrary(uri)`
450
+ - `User.addListener("capabilitiesChange", cb)`
451
+ - `User.addLibraryStateListener(uri, cb)`
452
+
453
+ ### `Content`
454
+
455
+ - `Content.getRecommendedContentItems(type)`
456
+ - `Content.getChildren(item)`
457
+
458
+ ### `Images`
459
+
460
+ - `Images.load(item, size)`
461
+
462
+ ### Hooks
463
+
464
+ Built on `useSyncExternalStore` over the native event streams. Subscribe in any component; no manual `addListener` cleanup required.
465
+
466
+ - `useSession()` — latest `SpotifySession` from auth events (or `null`)
467
+ - `useConnectionState()` — `disconnected` \| `connecting` \| `connected`
468
+ - `usePlayerState()` — full player snapshot
469
+ - `useCurrentTrack()` / `useIsPlaying()` / `usePlaybackPosition()` — derived from player state
470
+ - `useCapabilities()` — `canPlayOnDemand` and related flags
471
+ - `useLibraryState(uri)` — save state for one URI
472
+
473
+ ## Error codes by namespace
474
+
475
+ Every rejection is an instance of a namespace-specific subclass (`AuthError`, `AppRemoteError`, …) extending the abstract `SpotifyError` base. Catch with `instanceof` for typed `code` narrowing.
476
+
477
+ ### `AuthErrorCode`
478
+
479
+ | Code | When | What to do |
480
+ | --- | --- | --- |
481
+ | `USER_CANCELLED` | User closed auth or `Auth.cancelPending()` ran | Benign — no action |
482
+ | `AUTH_IN_PROGRESS` | Concurrent `Auth.authenticate()` or iOS stuck pending auth | `await Auth.cancelPending()` then retry |
483
+ | `INVALID_CONFIG` | Missing `clientID`, empty `scopes`, or bad plugin setup | Fix config plugin / `app.config`; run `expo prebuild` |
484
+ | `NETWORK_ERROR` | Device cannot reach `tokenSwapURL` / `tokenRefreshURL` | Check dev server URL, HTTPS, device network |
485
+ | `TOKEN_SWAP_FAILED` | Swap server returned non-2xx | Fix server; read status + body in `e.message` |
486
+ | `TOKEN_SWAP_PARSE_ERROR` | Swap response was not valid token JSON | Fix server response shape |
487
+ | `SPOTIFY_NOT_INSTALLED` | Spotify app not found (rare — web fallback may still run) | Prompt install or use web auth |
488
+ | `AUTH_ERROR` | Spotify rejected the authorization | Check Dashboard redirect URI, scopes, test users |
489
+ | `UNKNOWN` | Unexpected native failure | Read `e.message` / `e.cause`; file an issue with logs |
490
+
491
+ ### `AppRemoteErrorCode`
492
+
493
+ | Code | When | What to do |
494
+ | --- | --- | --- |
495
+ | `CONNECTION_FAILED` | `connect()` failed (app missing, refused, handshake error) | Open Spotify foreground; re-auth if token stale; retry once |
496
+ | `CONNECTION_LOST` | Connection dropped mid-session | `AppRemote.connect()` again with fresh token |
497
+ | `NOT_CONNECTED` | `AppRemote.*` called before connect completed | `await AppRemote.connect()` first |
498
+ | `UNKNOWN` | Unexpected IPC failure | Read `e.message`; retry connect |
499
+
500
+ ### `PlayerErrorCode`
501
+
502
+ | Code | When | What to do |
503
+ | --- | --- | --- |
504
+ | `NOT_CONNECTED` | `Player.*` before `AppRemote.connect()` | Connect App Remote first |
505
+ | `CONNECTION_LOST` | Dropped during a player call | Reconnect, then retry |
506
+ | `PREMIUM_REQUIRED` | `canPlayOnDemand === false` (Free tier) | Upgrade account or use shuffle/context play only |
507
+ | `INVALID_URI` | URI failed validation | Use `SpotifyURI.from()` |
508
+ | `INVALID_PARAMETER` | Bad argument (e.g. negative seek) | Fix caller |
509
+ | `OPERATION_NOT_ALLOWED` | Player restriction (can't skip, etc.) | Check `PlayerState` restrictions |
510
+ | `UNKNOWN` | Other native player error | Read `e.message` |
511
+
512
+ ### `UserErrorCode`
513
+
514
+ | Code | When | What to do |
515
+ | --- | --- | --- |
516
+ | `NOT_CONNECTED` | `User.*` before connect | Connect App Remote first |
517
+ | `CONNECTION_LOST` | Dropped during user API call | Reconnect |
518
+ | `INVALID_URI` | Bad library URI | Use `SpotifyURI.from()` |
519
+ | `OPERATION_NOT_ALLOWED` | Save/remove blocked (tier, region) | Check `LibraryState.canAdd` / capabilities |
520
+ | `UNKNOWN` | Other native user error | Read `e.message` |
521
+
522
+ ### `ContentErrorCode`
523
+
524
+ | Code | When | What to do |
525
+ | --- | --- | --- |
526
+ | `NOT_CONNECTED` | `Content.*` before connect | Connect App Remote first |
527
+ | `CONNECTION_LOST` | Dropped during browse | Reconnect |
528
+ | `CONTENT_API_UNAVAILABLE` | Spotify app too old for Content API | Update Spotify app |
529
+ | `UNKNOWN` | Other content error | Read `e.message` |
530
+
531
+ ### `ImagesErrorCode`
532
+
533
+ | Code | When | What to do |
534
+ | --- | --- | --- |
535
+ | `NOT_CONNECTED` | `Images.load()` before connect | Connect App Remote first |
536
+ | `INVALID_URI` | Item has no loadable image | Skip artwork or use placeholder |
537
+ | `IMAGE_LOAD_FAILED` | Spotify or disk write failed | Retry; check free space |
538
+ | `UNKNOWN` | Other image error | Read `e.message` |
539
+
540
+ ## Platform differences (parity)
541
+
542
+ | Topic | iOS | Android |
543
+ | --- | --- | --- |
544
+ | `AppRemote.connect(accessToken)` | Token passed to `SPTAppRemote` | Token accepted for API parity; SDK uses session cached in Spotify app from prior auth |
545
+ | `Auth.cancelPending()` | Clears stuck `SPTSessionManager` state | No-op |
546
+ | Refresh token without swap | Possible (iOS TOKEN flow) | Not available — use `tokenSwapURL` |
547
+ | `session.scopes` without swap | Granted scopes returned | Requested scopes only (not granted list) |
548
+ | Premium / player metadata | Full App Remote when Premium | Free accounts often lack track titles / on-demand play |
549
+ | Content / Images | Requires recent Spotify app | Same |
550
+
295
551
  ## Android implicit (TOKEN) flow is not recommended
296
552
 
297
- When `authenticateAsync` is called on Android **without** a `tokenSwapURL`, the Spotify Android SDK uses the implicit (TOKEN) flow. This flow has two hard limitations that **will not be fixed** — Spotify has deprecated it:
553
+ When `Auth.authenticate()` is called on Android **without** a `tokenSwapURL`, the Spotify Android SDK uses the implicit (TOKEN) flow. This flow has two hard limitations that **will not be fixed** — Spotify has deprecated it:
298
554
 
299
555
  1. **No `refreshToken`.** The Android SDK does not expose a refresh token for implicit grants. `session.refreshToken` will always be `null`.
300
556
  2. **`scopes` reflects what was requested, not what was granted.** The Android SDK does not return the actual granted scope list for TOKEN responses.
@@ -389,14 +645,38 @@ The `/swap` and `/refresh` API routes are served by the Expo dev server alongsid
389
645
  **`INVALID_CONFIG: Missing meta-data 'spotifyClientId'`**
390
646
  Run `expo prebuild` after adding the plugin to your config. The plugin injects the required `AndroidManifest.xml` entries.
391
647
 
392
- **`isAvailable()` returns `false` on Android 11+ release builds**
648
+ **`Auth.isAvailable()` returns `false` on Android 11+ release builds**
393
649
  Android 11+ requires a `<queries>` element to inspect other apps' package names. The module ships this in its `AndroidManifest.xml`; make sure you are not merging a custom manifest that removes it.
394
650
 
395
651
  **iOS: authentication never returns**
396
652
  Ensure your app's URL scheme is registered in Xcode under **Info → URL Types** and that it matches the `scheme` in the plugin config. The `expo prebuild` step does this automatically; if you have a bare workflow, check `CFBundleURLSchemes` in `Info.plist`.
397
653
 
398
654
  **`AUTH_IN_PROGRESS`**
399
- `authenticateAsync` was called while a previous call was still pending. Wait for the first call to resolve or reject before calling again.
655
+ `Auth.authenticate()` was called while a previous call was still pending. Usually a concurrent call wait for the first to settle.
656
+
657
+ On iOS this can also be a stuck state when Spotify never redirects back. Call [`Auth.cancelPending()`](#authcancelpending-promisevoid) before retrying.
658
+
659
+ **App Remote: `CONNECTION_FAILED` / `Connection refused` (iOS code 61)**
660
+ The Spotify app is installed but its App Remote transport is not accepting connections. Common causes:
661
+
662
+ 1. **Spotify is not in the foreground** — switch to Spotify, then retry `AppRemote.connect()`.
663
+ 2. **Connect ran before auth finished** — call `AppRemote.connect()` only after `Auth.authenticate()` resolves.
664
+ 3. **Stale access token** — refresh or re-authenticate, then reconnect.
665
+ 4. **Retry loop on startup** — avoid calling `connect()` on every render while `connectionState === "disconnected"`; gate on a one-shot flag or user action.
666
+
667
+ **Token swap: `NETWORK_ERROR` / `Could not connect to the server` to `http://127.0.0.1:…/swap`**
668
+ The swap URL must be reachable from the device running your app. During local dev, the Expo dev server must be running and serving API routes. Omit `tokenSwapURL` to test auth without a swap server (iOS TOKEN flow only; see [Android implicit flow](#android-implicit-token-flow-is-not-recommended)).
669
+
670
+ **Now playing shows no track title (Android, Free account)**
671
+ App Remote player state is limited for non-Premium users. Check `GET /v1/me` → `product` and `User.getCapabilities().canPlayOnDemand`.
672
+
673
+ ## Related docs
674
+
675
+ - [CONTEXT.md](./CONTEXT.md) — terminology (Auth SDK vs App Remote vs Web API)
676
+ - [docs/V1_PLAN.md](./docs/V1_PLAN.md) — implementation plan and release criteria
677
+ - [docs/QA_CHECKLIST.md](./docs/QA_CHECKLIST.md) — manual QA sign-off before `v1.0.0`
678
+ - [docs/RELEASE.md](./docs/RELEASE.md) — tag `v1.0.0` and cut the `v1` branch
679
+ - [ATTRIBUTION.md](./ATTRIBUTION.md) — third-party SDKs and scope boundaries
400
680
 
401
681
  ## Acknowledgements
402
682
 
@@ -48,6 +48,16 @@ repositories {
48
48
 
49
49
  dependencies {
50
50
  implementation "com.spotify.android:auth:4.0.1"
51
+
52
+ // Spotify App Remote SDK — NOT on Maven Central.
53
+ // Download spotify-app-remote-release-0.8.0.aar from
54
+ // https://github.com/spotify/android-sdk/releases and place it in android/libs/.
55
+ // See android/libs/SETUP.md for full instructions.
56
+ implementation files('libs/spotify-app-remote-release-0.8.0.aar')
57
+ // App Remote is consumed as a local AAR, so transitive deps like Gson must
58
+ // be declared explicitly to avoid NoClassDefFoundError at runtime.
59
+ implementation "com.google.code.gson:gson:2.11.0"
60
+
51
61
  implementation "com.squareup.okhttp3:okhttp:4.12.0"
52
62
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
53
63
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
@@ -3,6 +3,11 @@
3
3
  -keepclassmembers class com.spotify.sdk.android.auth.** { *; }
4
4
  -dontwarn com.spotify.sdk.android.auth.**
5
5
 
6
+ # Keep Spotify App Remote SDK classes
7
+ -keep class com.spotify.android.appremote.** { *; }
8
+ -keepclassmembers class com.spotify.android.appremote.** { *; }
9
+ -dontwarn com.spotify.android.appremote.**
10
+
6
11
  # Keep our module's data classes
7
12
  -keep class expo.modules.spotifysdk.** { *; }
8
13
  -keepclassmembers class expo.modules.spotifysdk.** { *; }
@@ -0,0 +1,29 @@
1
+ # Android App Remote SDK setup
2
+
3
+ The Spotify App Remote SDK for Android is not published to Maven Central and must be
4
+ downloaded manually.
5
+
6
+ ## Steps
7
+
8
+ 1. Go to <https://github.com/spotify/android-sdk/releases>
9
+ 2. Download the latest release archive (e.g. `spotify-android-sdk-x.x.x.zip`)
10
+ 3. Extract `spotify-app-remote-release-x.x.x.aar` from the archive
11
+ 4. Rename it to `spotify-app-remote-release-0.8.0.aar` (or update the version in
12
+ `android/build.gradle` to match the downloaded version)
13
+ 5. Place the `.aar` file in this directory (`android/libs/`)
14
+
15
+ The `flatDir` repository in `android/build.gradle` will pick it up automatically.
16
+
17
+ ## Why not Maven?
18
+
19
+ The Spotify Auth SDK (`com.spotify.android:auth`) is on Maven Central, but the App Remote
20
+ SDK is distributed exclusively via GitHub releases. This directory follows the same pattern
21
+ as the iOS side, where `SpotifyiOS.xcframework` is committed directly to `ios/SpotifySDK/`.
22
+
23
+ ## Proguard
24
+
25
+ `consumer-rules.pro` already keeps the App Remote classes:
26
+
27
+ ```
28
+ -keep class com.spotify.android.appremote.** { *; }
29
+ ```