@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.
- package/README.md +404 -140
- package/android/build.gradle +12 -2
- package/android/consumer-rules.pro +5 -0
- package/android/libs/SETUP.md +29 -0
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +146 -9
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +556 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +112 -0
- package/app.plugin.js +1 -1
- package/build/ExpoSpotifySDKModule.web.d.ts +23 -0
- package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
- package/build/ExpoSpotifySDKModule.web.js +74 -5
- package/build/ExpoSpotifySDKModule.web.js.map +1 -1
- package/build/app-remote/error.d.ts +8 -0
- package/build/app-remote/error.d.ts.map +1 -0
- package/build/app-remote/error.js +10 -0
- package/build/app-remote/error.js.map +1 -0
- package/build/app-remote/index.d.ts +79 -0
- package/build/app-remote/index.d.ts.map +1 -0
- package/build/app-remote/index.js +110 -0
- package/build/app-remote/index.js.map +1 -0
- package/build/auth/error.d.ts +8 -0
- package/build/auth/error.d.ts.map +1 -0
- package/build/auth/error.js +10 -0
- package/build/auth/error.js.map +1 -0
- package/build/auth/index.d.ts +124 -0
- package/build/auth/index.d.ts.map +1 -0
- package/build/auth/index.js +162 -0
- package/build/auth/index.js.map +1 -0
- package/build/content/error.d.ts +8 -0
- package/build/content/error.d.ts.map +1 -0
- package/build/content/error.js +10 -0
- package/build/content/error.js.map +1 -0
- package/build/content/index.d.ts +38 -0
- package/build/content/index.d.ts.map +1 -0
- package/build/content/index.js +56 -0
- package/build/content/index.js.map +1 -0
- package/build/error.d.ts +24 -0
- package/build/error.d.ts.map +1 -0
- package/build/error.js +25 -0
- package/build/error.js.map +1 -0
- package/build/hooks/index.d.ts +90 -0
- package/build/hooks/index.d.ts.map +1 -0
- package/build/hooks/index.js +326 -0
- package/build/hooks/index.js.map +1 -0
- package/build/images/error.d.ts +8 -0
- package/build/images/error.d.ts.map +1 -0
- package/build/images/error.js +10 -0
- package/build/images/error.js.map +1 -0
- package/build/images/index.d.ts +36 -0
- package/build/images/index.d.ts.map +1 -0
- package/build/images/index.js +59 -0
- package/build/images/index.js.map +1 -0
- package/build/index.d.ts +44 -56
- package/build/index.d.ts.map +1 -1
- package/build/index.js +49 -141
- package/build/index.js.map +1 -1
- package/build/player/error.d.ts +8 -0
- package/build/player/error.d.ts.map +1 -0
- package/build/player/error.js +10 -0
- package/build/player/error.js.map +1 -0
- package/build/player/index.d.ts +140 -0
- package/build/player/index.d.ts.map +1 -0
- package/build/player/index.js +137 -0
- package/build/player/index.js.map +1 -0
- package/build/uri/index.d.ts +55 -0
- package/build/uri/index.d.ts.map +1 -0
- package/build/uri/index.js +57 -0
- package/build/uri/index.js.map +1 -0
- package/build/user/error.d.ts +8 -0
- package/build/user/error.d.ts.map +1 -0
- package/build/user/error.js +10 -0
- package/build/user/error.js.map +1 -0
- package/build/user/index.d.ts +44 -0
- package/build/user/index.d.ts.map +1 -0
- package/build/user/index.js +97 -0
- package/build/user/index.js.map +1 -0
- package/ios/ExpoSpotifySDK.podspec +1 -1
- package/ios/ExpoSpotifySDKModule.swift +227 -5
- package/ios/SpotifyAppRemoteCoordinator.swift +939 -0
- package/ios/SpotifyAuthCoordinator.swift +53 -13
- package/ios/SpotifyTokenRefreshClient.swift +28 -0
- package/package.json +29 -16
- package/plugin/build/index.d.ts +20 -3
- package/plugin/build/index.js +22 -25
- package/plugin/build/types.d.ts +17 -1
- package/plugin/build/withSpotifySdkConfig.d.ts +4 -0
- package/plugin/build/withSpotifySdkConfig.js +27 -0
package/README.md
CHANGED
|
@@ -4,28 +4,70 @@
|
|
|
4
4
|
[](https://github.com/wwdrew/expo-spotify-sdk/actions/workflows/ci.yml)
|
|
5
5
|
[](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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
|
16
|
-
|
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
|
|
|
21
|
-
|
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
220
|
+
async function login() {
|
|
221
|
+
if (!Auth.isAvailable()) {
|
|
222
|
+
throw new Error("Install the Spotify app to continue");
|
|
223
|
+
}
|
|
152
224
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
247
|
+
function NowPlaying() {
|
|
248
|
+
const session = useSession();
|
|
249
|
+
const connectionState = useConnectionState();
|
|
250
|
+
const track = useCurrentTrack();
|
|
251
|
+
const isPlaying = useIsPlaying();
|
|
174
252
|
|
|
175
|
-
|
|
176
|
-
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (session == null || connectionState !== "disconnected") return;
|
|
255
|
+
void connectRemote(session.accessToken);
|
|
256
|
+
}, [session, connectionState]);
|
|
177
257
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
});
|
|
182
|
-
```
|
|
258
|
+
if (connectionState !== "connected") {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
183
261
|
|
|
184
|
-
|
|
262
|
+
return (
|
|
263
|
+
<Text>
|
|
264
|
+
{track?.name ?? "No track"} · {isPlaying ? "Playing" : "Paused"}
|
|
265
|
+
</Text>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
```
|
|
185
269
|
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
+
The [example app](./example) displays this as "Account tier: Premium / Free".
|
|
195
285
|
|
|
196
|
-
|
|
286
|
+
### Auto-connect App Remote
|
|
197
287
|
|
|
198
|
-
|
|
288
|
+
Avoid reconnect loops by gating on connection state and attempting at most once per session restore:
|
|
199
289
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
316
|
+
For iOS, if `connect()` fails with `CONNECTION_FAILED`, foreground the Spotify app and retry manually — see [Troubleshooting](#troubleshooting).
|
|
208
317
|
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
+
## Migration from v0.x
|
|
221
330
|
|
|
222
|
-
|
|
331
|
+
v0.x top-level functions remain exported but are deprecated (scheduled for removal in a future major).
|
|
223
332
|
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
await cancelPendingAuthAsync();
|
|
234
|
-
return authenticateAsync({ scopes: ["user-read-email", "streaming"] });
|
|
235
|
-
}
|
|
236
|
-
```
|
|
345
|
+
## API reference
|
|
237
346
|
|
|
238
|
-
|
|
347
|
+
### `Auth`
|
|
239
348
|
|
|
240
|
-
|
|
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
|
-
|
|
353
|
+
#### `Auth.authenticate(config): Promise<SpotifySession>`
|
|
245
354
|
|
|
246
|
-
|
|
355
|
+
Starts OAuth via the installed Spotify app (or web fallback). Throws [`AuthError`](#autherror) on failure.
|
|
247
356
|
|
|
248
|
-
|
|
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
|
-
|
|
|
251
|
-
|
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
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
|
-
|
|
371
|
+
#### `Auth.cancelPending(): Promise<void>`
|
|
257
372
|
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
382
|
+
#### `Auth.refresh(config): Promise<SpotifySession>`
|
|
273
383
|
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
+
#### `AuthError`
|
|
308
405
|
|
|
309
406
|
```ts
|
|
310
|
-
import {
|
|
407
|
+
import { Auth, AuthError } from "@wwdrew/expo-spotify-sdk";
|
|
311
408
|
|
|
312
409
|
try {
|
|
313
|
-
await
|
|
410
|
+
await Auth.authenticate({ scopes: ["streaming"] });
|
|
314
411
|
} catch (e) {
|
|
315
|
-
if (e instanceof
|
|
412
|
+
if (e instanceof AuthError) {
|
|
316
413
|
switch (e.code) {
|
|
317
|
-
case "USER_CANCELLED":
|
|
318
|
-
case "AUTH_IN_PROGRESS":
|
|
414
|
+
case "USER_CANCELLED":
|
|
415
|
+
case "AUTH_IN_PROGRESS":
|
|
319
416
|
return;
|
|
320
|
-
case "INVALID_CONFIG":
|
|
321
|
-
case "NETWORK_ERROR":
|
|
322
|
-
case "TOKEN_SWAP_FAILED":
|
|
323
|
-
case "TOKEN_SWAP_PARSE_ERROR":
|
|
324
|
-
case "SPOTIFY_NOT_INSTALLED":
|
|
325
|
-
case "AUTH_ERROR":
|
|
326
|
-
case "UNKNOWN":
|
|
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 `
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|