@wwdrew/expo-spotify-sdk 0.8.0 → 2.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 (87) hide show
  1. package/README.md +404 -140
  2. package/android/build.gradle +12 -2
  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 +146 -9
  6. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +556 -0
  7. package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +112 -0
  8. package/app.plugin.js +1 -1
  9. package/build/ExpoSpotifySDKModule.web.d.ts +23 -0
  10. package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
  11. package/build/ExpoSpotifySDKModule.web.js +74 -5
  12. package/build/ExpoSpotifySDKModule.web.js.map +1 -1
  13. package/build/app-remote/error.d.ts +8 -0
  14. package/build/app-remote/error.d.ts.map +1 -0
  15. package/build/app-remote/error.js +10 -0
  16. package/build/app-remote/error.js.map +1 -0
  17. package/build/app-remote/index.d.ts +79 -0
  18. package/build/app-remote/index.d.ts.map +1 -0
  19. package/build/app-remote/index.js +110 -0
  20. package/build/app-remote/index.js.map +1 -0
  21. package/build/auth/error.d.ts +8 -0
  22. package/build/auth/error.d.ts.map +1 -0
  23. package/build/auth/error.js +10 -0
  24. package/build/auth/error.js.map +1 -0
  25. package/build/auth/index.d.ts +124 -0
  26. package/build/auth/index.d.ts.map +1 -0
  27. package/build/auth/index.js +162 -0
  28. package/build/auth/index.js.map +1 -0
  29. package/build/content/error.d.ts +8 -0
  30. package/build/content/error.d.ts.map +1 -0
  31. package/build/content/error.js +10 -0
  32. package/build/content/error.js.map +1 -0
  33. package/build/content/index.d.ts +38 -0
  34. package/build/content/index.d.ts.map +1 -0
  35. package/build/content/index.js +56 -0
  36. package/build/content/index.js.map +1 -0
  37. package/build/error.d.ts +24 -0
  38. package/build/error.d.ts.map +1 -0
  39. package/build/error.js +25 -0
  40. package/build/error.js.map +1 -0
  41. package/build/hooks/index.d.ts +90 -0
  42. package/build/hooks/index.d.ts.map +1 -0
  43. package/build/hooks/index.js +326 -0
  44. package/build/hooks/index.js.map +1 -0
  45. package/build/images/error.d.ts +8 -0
  46. package/build/images/error.d.ts.map +1 -0
  47. package/build/images/error.js +10 -0
  48. package/build/images/error.js.map +1 -0
  49. package/build/images/index.d.ts +36 -0
  50. package/build/images/index.d.ts.map +1 -0
  51. package/build/images/index.js +59 -0
  52. package/build/images/index.js.map +1 -0
  53. package/build/index.d.ts +44 -56
  54. package/build/index.d.ts.map +1 -1
  55. package/build/index.js +49 -141
  56. package/build/index.js.map +1 -1
  57. package/build/player/error.d.ts +8 -0
  58. package/build/player/error.d.ts.map +1 -0
  59. package/build/player/error.js +10 -0
  60. package/build/player/error.js.map +1 -0
  61. package/build/player/index.d.ts +140 -0
  62. package/build/player/index.d.ts.map +1 -0
  63. package/build/player/index.js +137 -0
  64. package/build/player/index.js.map +1 -0
  65. package/build/uri/index.d.ts +55 -0
  66. package/build/uri/index.d.ts.map +1 -0
  67. package/build/uri/index.js +57 -0
  68. package/build/uri/index.js.map +1 -0
  69. package/build/user/error.d.ts +8 -0
  70. package/build/user/error.d.ts.map +1 -0
  71. package/build/user/error.js +10 -0
  72. package/build/user/error.js.map +1 -0
  73. package/build/user/index.d.ts +44 -0
  74. package/build/user/index.d.ts.map +1 -0
  75. package/build/user/index.js +97 -0
  76. package/build/user/index.js.map +1 -0
  77. package/ios/ExpoSpotifySDK.podspec +1 -1
  78. package/ios/ExpoSpotifySDKModule.swift +227 -5
  79. package/ios/SpotifyAppRemoteCoordinator.swift +939 -0
  80. package/ios/SpotifyAuthCoordinator.swift +53 -13
  81. package/ios/SpotifyTokenRefreshClient.swift +28 -0
  82. package/package.json +29 -16
  83. package/plugin/build/index.d.ts +20 -3
  84. package/plugin/build/index.js +22 -25
  85. package/plugin/build/types.d.ts +17 -1
  86. package/plugin/build/withSpotifySdkConfig.d.ts +4 -0
  87. package/plugin/build/withSpotifySdkConfig.js +27 -0
package/README.md CHANGED
@@ -4,28 +4,70 @@
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
- | `cancelPendingAuthAsync()` | ✅ | no-op | no-op |
19
- | `refreshSessionAsync` | ✅ | ✅ | |
20
- | Auth via installed Spotify app | ✅ | ✅ | |
21
- | 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 56** and releases as **`2.x`**. For Expo SDK 55, install **`1.x`** from the `v1` branch ([ADR-0005](./docs/adr/0005-sdk-lane-versioning.md)).
36
+
37
+ ## Public API (Auth + App Remote)
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.
22
62
 
23
63
  ## Quick start (Expo)
24
64
 
25
65
  ```sh
26
- # 1. Install
66
+ # 1. Install (Expo SDK 56+ — matches `main` / npm `2.x`)
27
67
  npx expo install @wwdrew/expo-spotify-sdk
28
68
 
69
+ # Expo SDK 55 only: npx expo install @wwdrew/expo-spotify-sdk@1
70
+
29
71
  # 2. Add the config plugin to app.config.ts / app.json (see Configuration below)
30
72
  # 3. Regenerate native projects
31
73
  npx expo prebuild
@@ -97,7 +139,29 @@ Replace `myapp`, `spotify-auth`, and `your-spotify-client-id` with your own valu
97
139
 
98
140
  ## Configuration
99
141
 
100
- Add the plugin to your `app.config.ts` (or `app.json`):
142
+ Add the plugin to your `app.config.ts` (or `app.json`).
143
+
144
+ ### Typed plugin (Expo SDK 56+)
145
+
146
+ Import from `@wwdrew/expo-spotify-sdk/plugin` for autocomplete and type-checked options:
147
+
148
+ ```ts
149
+ import type { ExpoConfig } from "expo/config";
150
+ import withSpotifySdk from "@wwdrew/expo-spotify-sdk/plugin";
151
+
152
+ export default ({ config }: { config: ExpoConfig }): ExpoConfig => ({
153
+ ...config,
154
+ plugins: [
155
+ withSpotifySdk({
156
+ clientID: "your-spotify-client-id",
157
+ scheme: "myapp",
158
+ host: "spotify-auth",
159
+ }),
160
+ ],
161
+ });
162
+ ```
163
+
164
+ ### String tuple (all SDK versions)
101
165
 
102
166
  ```ts
103
167
  export default {
@@ -138,201 +202,379 @@ The redirect URI registered in your [Spotify Developer Dashboard](https://develo
138
202
 
139
203
  ## Usage
140
204
 
205
+ Typical integration: authenticate, connect App Remote, then read/control playback via hooks.
206
+
141
207
  ```ts
208
+ import { useEffect } from "react";
142
209
  import {
143
- isAvailable,
144
- authenticateAsync,
145
- refreshSessionAsync,
146
- addSessionChangeListener,
147
- SpotifyError,
210
+ Auth,
211
+ AppRemote,
212
+ AuthError,
213
+ AppRemoteError,
214
+ useSession,
215
+ useConnectionState,
216
+ useCurrentTrack,
217
+ useIsPlaying,
148
218
  } from "@wwdrew/expo-spotify-sdk";
149
219
 
150
- // Check whether the Spotify app is installed
151
- const spotifyInstalled = isAvailable();
220
+ async function login() {
221
+ if (!Auth.isAvailable()) {
222
+ throw new Error("Install the Spotify app to continue");
223
+ }
152
224
 
153
- // Authenticate
154
- try {
155
- const session = await authenticateAsync({
156
- scopes: ["user-read-email", "streaming"],
225
+ // On iOS, clear a leaked in-flight auth before retrying.
226
+ await Auth.cancelPending();
227
+
228
+ return Auth.authenticate({
229
+ scopes: ["app-remote-control", "user-read-playback-state", "streaming"],
157
230
  tokenSwapURL: "https://your-server.example.com/swap",
158
231
  tokenRefreshURL: "https://your-server.example.com/refresh",
159
232
  });
233
+ }
160
234
 
161
- console.log(session.accessToken); // use with Spotify Web API
162
- console.log(session.refreshToken); // store securely for later refresh
163
- console.log(session.expirationDate); // Unix epoch milliseconds
164
- console.log(session.scopes); // granted scopes
165
- } catch (e) {
166
- if (e instanceof SpotifyError) {
167
- if (e.code === "USER_CANCELLED") return; // user backed out — benign
168
- console.error(`[${e.code}] ${e.message}`);
235
+ async function connectRemote(accessToken: string) {
236
+ try {
237
+ await AppRemote.connect(accessToken);
238
+ } catch (e) {
239
+ if (e instanceof AppRemoteError && e.code === "CONNECTION_FAILED") {
240
+ // Spotify's IPC transport is not ready — open Spotify and retry.
241
+ // See "App Remote connection failed" in Troubleshooting.
242
+ }
243
+ throw e;
169
244
  }
170
245
  }
171
- ```
172
246
 
173
- ### Refresh a session
247
+ function NowPlaying() {
248
+ const session = useSession();
249
+ const connectionState = useConnectionState();
250
+ const track = useCurrentTrack();
251
+ const isPlaying = useIsPlaying();
174
252
 
175
- ```ts
176
- import { refreshSessionAsync } from "@wwdrew/expo-spotify-sdk";
253
+ useEffect(() => {
254
+ if (session == null || connectionState !== "disconnected") return;
255
+ void connectRemote(session.accessToken);
256
+ }, [session, connectionState]);
177
257
 
178
- const refreshed = await refreshSessionAsync({
179
- refreshToken: storedRefreshToken,
180
- tokenRefreshURL: "https://your-server.example.com/refresh",
181
- });
182
- ```
258
+ if (connectionState !== "connected") {
259
+ return null;
260
+ }
183
261
 
184
- ## API reference
262
+ return (
263
+ <Text>
264
+ {track?.name ?? "No track"} · {isPlaying ? "Playing" : "Paused"}
265
+ </Text>
266
+ );
267
+ }
268
+ ```
185
269
 
186
- ### `isAvailable(): boolean`
270
+ 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.
187
271
 
188
- Returns `true` if the Spotify app is installed on the device. Always returns `false` on web. Does not throw.
272
+ ### Check account tier (Web API)
189
273
 
190
- ---
274
+ App Remote does not expose Premium status. Call Spotify's Web API with the access token from `Auth.authenticate()`:
191
275
 
192
- ### `authenticateAsync(config: SpotifyConfig): Promise<SpotifySession>`
276
+ ```ts
277
+ const res = await fetch("https://api.spotify.com/v1/me", {
278
+ headers: { Authorization: `Bearer ${session.accessToken}` },
279
+ });
280
+ const profile = await res.json();
281
+ const isPremium = profile.product === "premium";
282
+ ```
193
283
 
194
- Starts a Spotify OAuth flow. If the Spotify app is installed it authenticates natively; otherwise it falls back to the Spotify web login page.
284
+ The [example app](./example) displays this as "Account tier: Premium / Free".
195
285
 
196
- **Throws [`SpotifyError`](#spotifyerror)** on failure.
286
+ ### Auto-connect App Remote
197
287
 
198
- **Parameters (`SpotifyConfig`):**
288
+ Avoid reconnect loops by gating on connection state and attempting at most once per session restore:
199
289
 
200
- | Field | Type | Required | Description |
201
- | ----------------- | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
202
- | `scopes` | `SpotifyScope[]` | ✅ | OAuth scopes to request. Must contain at least one entry. |
203
- | `tokenSwapURL` | `string` | — | URL of your token swap server endpoint. Triggers CODE flow (recommended). Required on Android to receive a `refreshToken`. |
204
- | `tokenRefreshURL` | `string` | — | URL of your token refresh server endpoint. Used by iOS SDK natively and by `refreshSessionAsync` on both platforms. |
205
- | `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. |
290
+ ```ts
291
+ import { useEffect, useRef } from "react";
292
+ import { AppRemote, useConnectionState, useSession } from "@wwdrew/expo-spotify-sdk";
293
+
294
+ /**
295
+ * Connect when a session exists and App Remote is disconnected.
296
+ * Set `once` to true to avoid retry spam after failures (recommended on cold start).
297
+ */
298
+ export function useAutoConnectAppRemote(options?: { once?: boolean }) {
299
+ const session = useSession();
300
+ const connectionState = useConnectionState();
301
+ const attemptedRef = useRef(false);
302
+
303
+ useEffect(() => {
304
+ const token = session?.accessToken;
305
+ if (!token || connectionState !== "disconnected") return;
306
+ if (options?.once && attemptedRef.current) return;
307
+
308
+ attemptedRef.current = true;
309
+ void AppRemote.connect(token).catch(() => {
310
+ // Surface via AppRemote.addListener("connectionError") or your UI.
311
+ });
312
+ }, [session?.accessToken, connectionState, options?.once]);
313
+ }
314
+ ```
206
315
 
207
- **Returns (`SpotifySession`):**
316
+ For iOS, if `connect()` fails with `CONNECTION_FAILED`, foreground the Spotify app and retry manually — see [Troubleshooting](#troubleshooting).
208
317
 
209
- | Field | Type | Description |
210
- | ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
211
- | `accessToken` | `string` | OAuth access token. Use as `Authorization: Bearer <token>` with the Spotify Web API. |
212
- | `refreshToken` | `string \| null` | Refresh token. `null` on Android when no `tokenSwapURL` is provided — see [Android implicit flow](#android-implicit-token-flow-is-not-recommended). |
213
- | `expirationDate` | `number` | Token expiry as Unix epoch **milliseconds**. |
214
- | `scopes` | `SpotifyScope[]` | Granted scopes. On Android TOKEN flow, reflects requested scopes (Spotify does not expose granted scopes in the implicit flow). |
318
+ ## Spotify Premium and App Remote
215
319
 
216
- ---
320
+ | Concern | Auth (`Auth.*`) | App Remote (`AppRemote.*`, `Player.*`, …) |
321
+ | --- | --- | --- |
322
+ | Spotify app installed | Recommended (native UX) | **Required** |
323
+ | Spotify app running | No | **Required** — connect talks to the running Spotify process over IPC |
324
+ | Spotify Premium | No | **Required** for reliable on-demand playback and rich player state (track name, transport, browse) |
325
+ | Free account | Auth works | `Player.play()` may fail with `PREMIUM_REQUIRED`; `User.getCapabilities().canPlayOnDemand` is `false` |
217
326
 
218
- ### `cancelPendingAuthAsync(): Promise<void>`
327
+ **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.
219
328
 
220
- Forcibly cancels any in-flight `authenticateAsync` call. Resolves once the native coordinator's pending state is cleared. No-op when nothing is in flight.
329
+ ## Migration from v0.x
221
330
 
222
- **Why this exists:** the iOS `SPTSessionManager` delegate callbacks (`didInitiate` / `didFailWith`) are not guaranteed to fire — for example when Spotify never redirects back to the host app, or the host process is backgrounded mid-flow. When that happens the coordinator's pending continuation leaks and every subsequent `authenticateAsync` rejects immediately with `AUTH_IN_PROGRESS` until the process restarts.
331
+ v0.x top-level functions remain exported but are deprecated (scheduled for removal in a future major).
223
332
 
224
- **When to call it:** defensively, before each `authenticateAsync`, so retries always start from a clean slate. The cost is one cheap async hop when nothing is leaked.
333
+ | v0.x | v1 |
334
+ | --- | --- |
335
+ | `isAvailable()` | `Auth.isAvailable()` |
336
+ | `authenticateAsync(config)` | `Auth.authenticate(config)` |
337
+ | `refreshSessionAsync(config)` | `Auth.refresh(config)` |
338
+ | `cancelPendingAuthAsync()` | `Auth.cancelPending()` |
339
+ | `addSessionChangeListener(cb)` | `Auth.addListener("sessionChange", cb)` |
340
+ | `SpotifyError` (auth throws) | `AuthError` (or `instanceof SpotifyError` as catch-all) |
341
+ | `SpotifyErrorCode` | `AuthErrorCode` |
225
342
 
226
- ```ts
227
- import {
228
- authenticateAsync,
229
- cancelPendingAuthAsync,
230
- } from "@wwdrew/expo-spotify-sdk";
343
+ `SpotifySession`, `SpotifyScope`, and config shapes are unchanged. For auth-specific `e.code` narrowing, use `instanceof AuthError` instead of `instanceof SpotifyError`.
231
344
 
232
- async function login() {
233
- await cancelPendingAuthAsync();
234
- return authenticateAsync({ scopes: ["user-read-email", "streaming"] });
235
- }
236
- ```
345
+ ## API reference
237
346
 
238
- If a pending call is cancelled this way, its original `authenticateAsync` promise rejects with `SpotifyError` `code: "USER_CANCELLED"`.
347
+ ### `Auth`
239
348
 
240
- **Platform notes:** no-op on Android (the Kotlin coordinator self-cleans via structured concurrency) and on web. Safe to call unconditionally.
349
+ #### `Auth.isAvailable(): boolean`
241
350
 
242
- ---
351
+ Returns `true` if the Spotify app is installed. Does not throw (not available on unsupported platforms).
243
352
 
244
- ### `refreshSessionAsync(options): Promise<SpotifySession>`
353
+ #### `Auth.authenticate(config): Promise<SpotifySession>`
245
354
 
246
- Exchanges a refresh token for a new access token via your token refresh server.
355
+ Starts OAuth via the installed Spotify app (or web fallback). Throws [`AuthError`](#autherror) on failure.
247
356
 
248
- **Parameters:**
357
+ | Field | Type | Required | Description |
358
+ | --- | --- | --- | --- |
359
+ | `scopes` | `SpotifyScope[]` | ✅ | At least one scope. Include `app-remote-control` for App Remote. |
360
+ | `tokenSwapURL` | `string` | — | Code flow + server swap (recommended). Required on Android for a `refreshToken`. |
361
+ | `tokenRefreshURL` | `string` | — | Refresh endpoint for iOS SDK and `Auth.refresh()`. |
362
+ | `showDialog` | `boolean` | — | Force the consent screen. Default `false`. |
249
363
 
250
- | Field | Type | Required | Description |
251
- | ----------------- | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
252
- | `refreshToken` | `string` | | The refresh token from a previous `authenticateAsync` call. |
253
- | `tokenRefreshURL` | `string` | | URL of your token refresh server endpoint. |
254
- | `scopes` | `SpotifyScope[]` | — | Previously-granted scopes. Used as a fallback when the refresh response omits the `scope` field. **Pass through the previous session's `scopes`** to avoid silently losing scope info. |
364
+ | Return field | Type | Description |
365
+ | --- | --- | --- |
366
+ | `accessToken` | `string` | Bearer token for Web API and `AppRemote.connect()`. |
367
+ | `refreshToken` | `string \| null` | `null` on Android without `tokenSwapURL`. |
368
+ | `expirationDate` | `number` | Expiry as Unix epoch **milliseconds**. |
369
+ | `scopes` | `SpotifyScope[]` | Granted scopes (requested-only on Android TOKEN flow). |
255
370
 
256
- **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.
371
+ #### `Auth.cancelPending(): Promise<void>`
257
372
 
258
- **Why pass `scopes`:** Spotify's refresh endpoint only returns `scope` when the granted scope set has changed since the last issuance — most refresh responses omit it. Without `scopes` plumbed through, the returned session's `scopes` will be `[]` on every refresh that doesn't include the field, even though the access token itself still carries the same scopes.
373
+ 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.
259
374
 
260
375
  ```ts
261
- const refreshed = await refreshSessionAsync({
262
- refreshToken: previous.refreshToken,
263
- tokenRefreshURL: "https://your-server.example.com/refresh",
264
- scopes: previous.scopes,
265
- });
376
+ await Auth.cancelPending();
377
+ const session = await Auth.authenticate({ scopes: ["streaming"] });
266
378
  ```
267
379
 
268
- ---
269
-
270
- ### `addSessionChangeListener(listener): Subscription`
380
+ A cancelled call rejects with `AuthError` `code: "USER_CANCELLED"`.
271
381
 
272
- 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.
382
+ #### `Auth.refresh(config): Promise<SpotifySession>`
273
383
 
274
- Returns a `Subscription` object; call `.remove()` to unsubscribe.
384
+ Exchanges a refresh token via your refresh server. Pass through `scopes` from the previous session when the refresh response omits `scope`.
275
385
 
276
386
  ```ts
277
- import { addSessionChangeListener } from "@wwdrew/expo-spotify-sdk";
278
-
279
- const sub = addSessionChangeListener((event) => {
280
- switch (event.type) {
281
- case "didInitiate":
282
- case "didRenew":
283
- store.setSession(event.session); // { accessToken, refreshToken, expirationDate, scopes }
284
- break;
285
- case "didFail":
286
- console.error(`[${event.error.code}] ${event.error.message}`);
287
- break;
288
- }
387
+ const refreshed = await Auth.refresh({
388
+ refreshToken: previous.refreshToken!,
389
+ tokenRefreshURL: "https://your-server.example.com/refresh",
390
+ scopes: previous.scopes,
289
391
  });
290
-
291
- // When the subscribing component unmounts:
292
- sub.remove();
293
392
  ```
294
393
 
295
- **Event types (`SpotifySessionChangeEvent`):**
296
-
297
- | `type` | Payload | Fired when |
298
- |---|---|---|
299
- | `"didInitiate"` | `{ session: SpotifySession }` | `authenticateAsync` succeeded |
300
- | `"didRenew"` | `{ session: SpotifySession }` | `refreshSessionAsync` succeeded |
301
- | `"didFail"` | `{ error: { code, message } }` | Either function rejected |
394
+ #### `Auth.addListener("sessionChange", listener): Subscription`
302
395
 
303
- ---
396
+ Central place to persist tokens. Fires for every `authenticate` / `refresh` — including calls you did not `await`.
304
397
 
305
- ### `SpotifyError`
398
+ | `type` | Payload | When |
399
+ | --- | --- | --- |
400
+ | `"didInitiate"` | `{ session }` | `Auth.authenticate()` succeeded |
401
+ | `"didRenew"` | `{ session }` | `Auth.refresh()` succeeded |
402
+ | `"didFail"` | `{ error: { code, message } }` | Either call failed |
306
403
 
307
- All rejections from `authenticateAsync` and `refreshSessionAsync` are instances of `SpotifyError`:
404
+ #### `AuthError`
308
405
 
309
406
  ```ts
310
- import { SpotifyError } from "@wwdrew/expo-spotify-sdk";
407
+ import { Auth, AuthError } from "@wwdrew/expo-spotify-sdk";
311
408
 
312
409
  try {
313
- await authenticateAsync({ scopes: ["streaming"] });
410
+ await Auth.authenticate({ scopes: ["streaming"] });
314
411
  } catch (e) {
315
- if (e instanceof SpotifyError) {
412
+ if (e instanceof AuthError) {
316
413
  switch (e.code) {
317
- case "USER_CANCELLED": // user closed the auth screen — benign
318
- case "AUTH_IN_PROGRESS": // concurrent call, or iOS stuck state — see cancelPendingAuthAsync
414
+ case "USER_CANCELLED":
415
+ case "AUTH_IN_PROGRESS":
319
416
  return;
320
- case "INVALID_CONFIG": // missing clientID / scopes / tokenSwapURL
321
- case "NETWORK_ERROR": // connectivity failure during token swap
322
- case "TOKEN_SWAP_FAILED": // swap server returned non-2xx (e.message has status + body)
323
- case "TOKEN_SWAP_PARSE_ERROR": // swap server returned invalid JSON
324
- case "SPOTIFY_NOT_INSTALLED": // Spotify app not found (rare — most flows fall back to web)
325
- case "AUTH_ERROR": // Spotify returned an error (e.message has detail)
326
- case "UNKNOWN": // unexpected failure
417
+ case "INVALID_CONFIG":
418
+ case "NETWORK_ERROR":
419
+ case "TOKEN_SWAP_FAILED":
420
+ case "TOKEN_SWAP_PARSE_ERROR":
421
+ case "SPOTIFY_NOT_INSTALLED":
422
+ case "AUTH_ERROR":
423
+ case "UNKNOWN":
327
424
  reportError(e);
328
425
  }
329
426
  }
330
427
  }
331
428
  ```
332
429
 
430
+ `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.
431
+
432
+ ### `AppRemote`
433
+
434
+ Connects to the **running** Spotify app. All `Player`, `User`, `Content`, and `Images` calls require an active connection.
435
+
436
+ | Method | Description |
437
+ | --- | --- |
438
+ | `connect(accessToken)` | Open IPC to Spotify. Resolves when connected. |
439
+ | `disconnect()` | Tear down connection. |
440
+ | `isConnected()` | Synchronous snapshot. |
441
+ | `getConnectionState()` | `"disconnected"` \| `"connecting"` \| `"connected"`. |
442
+ | `addListener("connectionStateChange", cb)` | State transitions. |
443
+ | `addListener("connectionError", cb)` | Failures and drops (`AppRemoteError` codes). |
444
+
445
+ **Platform notes:**
446
+
447
+ - **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.
448
+ - **Android:** `accessToken` is accepted for API parity; the SDK uses the session cached in the Spotify app from your prior `Auth.authenticate()` call.
449
+
450
+ Calling `connect()` while already connected is a no-op.
451
+
452
+ ### `Player`
453
+
454
+ - `Player.play(uri)`
455
+ - `Player.pause()`
456
+ - `Player.resume()`
457
+ - `Player.skipNext()`
458
+ - `Player.skipPrevious()`
459
+ - `Player.seekTo(positionMs)`
460
+ - `Player.setShuffle(enabled)`
461
+ - `Player.setRepeatMode(mode)`
462
+ - `Player.setPodcastPlaybackSpeed(speed)`
463
+ - `Player.queue(uri)`
464
+ - `Player.getPlayerState()`
465
+ - `Player.getCrossfadeState()`
466
+ - `Player.addListener("playerStateChange", cb)`
467
+
468
+ ### `User`
469
+
470
+ - `User.getCapabilities()`
471
+ - `User.getLibraryState(uri)`
472
+ - `User.addToLibrary(uri)`
473
+ - `User.removeFromLibrary(uri)`
474
+ - `User.addListener("capabilitiesChange", cb)`
475
+ - `User.addLibraryStateListener(uri, cb)`
476
+
477
+ ### `Content`
478
+
479
+ - `Content.getRecommendedContentItems(type)`
480
+ - `Content.getChildren(item)`
481
+
482
+ ### `Images`
483
+
484
+ - `Images.load(item, size)`
485
+
486
+ ### Hooks
487
+
488
+ Built on `useSyncExternalStore` over the native event streams. Subscribe in any component; no manual `addListener` cleanup required.
489
+
490
+ - `useSession()` — latest `SpotifySession` from auth events (or `null`)
491
+ - `useConnectionState()` — `disconnected` \| `connecting` \| `connected`
492
+ - `usePlayerState()` — full player snapshot
493
+ - `useCurrentTrack()` / `useIsPlaying()` / `usePlaybackPosition()` — derived from player state
494
+ - `useCapabilities()` — `canPlayOnDemand` and related flags
495
+ - `useLibraryState(uri)` — save state for one URI
496
+
497
+ ## Error codes by namespace
498
+
499
+ Every rejection is an instance of a namespace-specific subclass (`AuthError`, `AppRemoteError`, …) extending the abstract `SpotifyError` base. Catch with `instanceof` for typed `code` narrowing.
500
+
501
+ ### `AuthErrorCode`
502
+
503
+ | Code | When | What to do |
504
+ | --- | --- | --- |
505
+ | `USER_CANCELLED` | User closed auth or `Auth.cancelPending()` ran | Benign — no action |
506
+ | `AUTH_IN_PROGRESS` | Concurrent `Auth.authenticate()` or iOS stuck pending auth | `await Auth.cancelPending()` then retry |
507
+ | `INVALID_CONFIG` | Missing `clientID`, empty `scopes`, or bad plugin setup | Fix config plugin / `app.config`; run `expo prebuild` |
508
+ | `NETWORK_ERROR` | Device cannot reach `tokenSwapURL` / `tokenRefreshURL` | Check dev server URL, HTTPS, device network |
509
+ | `TOKEN_SWAP_FAILED` | Swap server returned non-2xx | Fix server; read status + body in `e.message` |
510
+ | `TOKEN_SWAP_PARSE_ERROR` | Swap response was not valid token JSON | Fix server response shape |
511
+ | `SPOTIFY_NOT_INSTALLED` | Spotify app not found (rare — web fallback may still run) | Prompt install or use web auth |
512
+ | `AUTH_ERROR` | Spotify rejected the authorization | Check Dashboard redirect URI, scopes, test users |
513
+ | `UNKNOWN` | Unexpected native failure | Read `e.message` / `e.cause`; file an issue with logs |
514
+
515
+ ### `AppRemoteErrorCode`
516
+
517
+ | Code | When | What to do |
518
+ | --- | --- | --- |
519
+ | `CONNECTION_FAILED` | `connect()` failed (app missing, refused, handshake error) | Open Spotify foreground; re-auth if token stale; retry once |
520
+ | `CONNECTION_LOST` | Connection dropped mid-session | `AppRemote.connect()` again with fresh token |
521
+ | `NOT_CONNECTED` | `AppRemote.*` called before connect completed | `await AppRemote.connect()` first |
522
+ | `UNKNOWN` | Unexpected IPC failure | Read `e.message`; retry connect |
523
+
524
+ ### `PlayerErrorCode`
525
+
526
+ | Code | When | What to do |
527
+ | --- | --- | --- |
528
+ | `NOT_CONNECTED` | `Player.*` before `AppRemote.connect()` | Connect App Remote first |
529
+ | `CONNECTION_LOST` | Dropped during a player call | Reconnect, then retry |
530
+ | `PREMIUM_REQUIRED` | `canPlayOnDemand === false` (Free tier) | Upgrade account or use shuffle/context play only |
531
+ | `INVALID_URI` | URI failed validation | Use `SpotifyURI.from()` |
532
+ | `INVALID_PARAMETER` | Bad argument (e.g. negative seek) | Fix caller |
533
+ | `OPERATION_NOT_ALLOWED` | Player restriction (can't skip, etc.) | Check `PlayerState` restrictions |
534
+ | `UNKNOWN` | Other native player error | Read `e.message` |
535
+
536
+ ### `UserErrorCode`
537
+
538
+ | Code | When | What to do |
539
+ | --- | --- | --- |
540
+ | `NOT_CONNECTED` | `User.*` before connect | Connect App Remote first |
541
+ | `CONNECTION_LOST` | Dropped during user API call | Reconnect |
542
+ | `INVALID_URI` | Bad library URI | Use `SpotifyURI.from()` |
543
+ | `OPERATION_NOT_ALLOWED` | Save/remove blocked (tier, region) | Check `LibraryState.canAdd` / capabilities |
544
+ | `UNKNOWN` | Other native user error | Read `e.message` |
545
+
546
+ ### `ContentErrorCode`
547
+
548
+ | Code | When | What to do |
549
+ | --- | --- | --- |
550
+ | `NOT_CONNECTED` | `Content.*` before connect | Connect App Remote first |
551
+ | `CONNECTION_LOST` | Dropped during browse | Reconnect |
552
+ | `CONTENT_API_UNAVAILABLE` | Spotify app too old for Content API | Update Spotify app |
553
+ | `UNKNOWN` | Other content error | Read `e.message` |
554
+
555
+ ### `ImagesErrorCode`
556
+
557
+ | Code | When | What to do |
558
+ | --- | --- | --- |
559
+ | `NOT_CONNECTED` | `Images.load()` before connect | Connect App Remote first |
560
+ | `INVALID_URI` | Item has no loadable image | Skip artwork or use placeholder |
561
+ | `IMAGE_LOAD_FAILED` | Spotify or disk write failed | Retry; check free space |
562
+ | `UNKNOWN` | Other image error | Read `e.message` |
563
+
564
+ ## Platform differences (parity)
565
+
566
+ | Topic | iOS | Android |
567
+ | --- | --- | --- |
568
+ | `AppRemote.connect(accessToken)` | Token passed to `SPTAppRemote` | Token accepted for API parity; SDK uses session cached in Spotify app from prior auth |
569
+ | `Auth.cancelPending()` | Clears stuck `SPTSessionManager` state | No-op |
570
+ | Refresh token without swap | Possible (iOS TOKEN flow) | Not available — use `tokenSwapURL` |
571
+ | `session.scopes` without swap | Granted scopes returned | Requested scopes only (not granted list) |
572
+ | Premium / player metadata | Full App Remote when Premium | Free accounts often lack track titles / on-demand play |
573
+ | Content / Images | Requires recent Spotify app | Same |
574
+
333
575
  ## Android implicit (TOKEN) flow is not recommended
334
576
 
335
- 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:
577
+ 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:
336
578
 
337
579
  1. **No `refreshToken`.** The Android SDK does not expose a refresh token for implicit grants. `session.refreshToken` will always be `null`.
338
580
  2. **`scopes` reflects what was requested, not what was granted.** The Android SDK does not return the actual granted scope list for TOKEN responses.
@@ -427,16 +669,38 @@ The `/swap` and `/refresh` API routes are served by the Expo dev server alongsid
427
669
  **`INVALID_CONFIG: Missing meta-data 'spotifyClientId'`**
428
670
  Run `expo prebuild` after adding the plugin to your config. The plugin injects the required `AndroidManifest.xml` entries.
429
671
 
430
- **`isAvailable()` returns `false` on Android 11+ release builds**
672
+ **`Auth.isAvailable()` returns `false` on Android 11+ release builds**
431
673
  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.
432
674
 
433
675
  **iOS: authentication never returns**
434
676
  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`.
435
677
 
436
678
  **`AUTH_IN_PROGRESS`**
437
- `authenticateAsync` was called while a previous call was still pending. Usually this means a concurrent call — wait for the first one to resolve.
679
+ `Auth.authenticate()` was called while a previous call was still pending. Usually a concurrent call — wait for the first to settle.
680
+
681
+ On iOS this can also be a stuck state when Spotify never redirects back. Call [`Auth.cancelPending()`](#authcancelpending-promisevoid) before retrying.
682
+
683
+ **App Remote: `CONNECTION_FAILED` / `Connection refused` (iOS code 61)**
684
+ The Spotify app is installed but its App Remote transport is not accepting connections. Common causes:
685
+
686
+ 1. **Spotify is not in the foreground** — switch to Spotify, then retry `AppRemote.connect()`.
687
+ 2. **Connect ran before auth finished** — call `AppRemote.connect()` only after `Auth.authenticate()` resolves.
688
+ 3. **Stale access token** — refresh or re-authenticate, then reconnect.
689
+ 4. **Retry loop on startup** — avoid calling `connect()` on every render while `connectionState === "disconnected"`; gate on a one-shot flag or user action.
690
+
691
+ **Token swap: `NETWORK_ERROR` / `Could not connect to the server` to `http://127.0.0.1:…/swap`**
692
+ 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)).
693
+
694
+ **Now playing shows no track title (Android, Free account)**
695
+ App Remote player state is limited for non-Premium users. Check `GET /v1/me` → `product` and `User.getCapabilities().canPlayOnDemand`.
696
+
697
+ ## Related docs
438
698
 
439
- On iOS this can also be a stuck state: the SPTSessionManager delegate callbacks aren't guaranteed to fire (e.g. Spotify never redirected back to the app), so the previous call's continuation leaks and every retry rejects immediately. Call [`cancelPendingAuthAsync()`](#cancelpendingauthasync-promisevoid) before retrying — or, defensively, before every `authenticateAsync`.
699
+ - [CONTEXT.md](./CONTEXT.md) terminology (Auth SDK vs App Remote vs Web API)
700
+ - [docs/V1_PLAN.md](./docs/V1_PLAN.md) — implementation plan and release criteria
701
+ - [docs/QA_CHECKLIST.md](./docs/QA_CHECKLIST.md) — manual QA before a `2.x` release on `main` (or `1.x` on `v1`)
702
+ - [docs/RELEASE.md](./docs/RELEASE.md) — Release Please on `main` (`2.x`); maintenance releases from `v1` (`1.x`)
703
+ - [ATTRIBUTION.md](./ATTRIBUTION.md) — third-party SDKs and scope boundaries
440
704
 
441
705
  ## Acknowledgements
442
706