@wwdrew/expo-spotify-sdk 0.8.0 → 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.
- package/README.md +378 -138
- package/android/build.gradle +10 -0
- 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/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 +136 -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/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 +4 -3
package/README.md
CHANGED
|
@@ -4,21 +4,61 @@
|
|
|
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 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.
|
|
22
62
|
|
|
23
63
|
## Quick start (Expo)
|
|
24
64
|
|
|
@@ -138,201 +178,379 @@ The redirect URI registered in your [Spotify Developer Dashboard](https://develo
|
|
|
138
178
|
|
|
139
179
|
## Usage
|
|
140
180
|
|
|
181
|
+
Typical integration: authenticate, connect App Remote, then read/control playback via hooks.
|
|
182
|
+
|
|
141
183
|
```ts
|
|
184
|
+
import { useEffect } from "react";
|
|
142
185
|
import {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
186
|
+
Auth,
|
|
187
|
+
AppRemote,
|
|
188
|
+
AuthError,
|
|
189
|
+
AppRemoteError,
|
|
190
|
+
useSession,
|
|
191
|
+
useConnectionState,
|
|
192
|
+
useCurrentTrack,
|
|
193
|
+
useIsPlaying,
|
|
148
194
|
} from "@wwdrew/expo-spotify-sdk";
|
|
149
195
|
|
|
150
|
-
|
|
151
|
-
|
|
196
|
+
async function login() {
|
|
197
|
+
if (!Auth.isAvailable()) {
|
|
198
|
+
throw new Error("Install the Spotify app to continue");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// On iOS, clear a leaked in-flight auth before retrying.
|
|
202
|
+
await Auth.cancelPending();
|
|
152
203
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const session = await authenticateAsync({
|
|
156
|
-
scopes: ["user-read-email", "streaming"],
|
|
204
|
+
return Auth.authenticate({
|
|
205
|
+
scopes: ["app-remote-control", "user-read-playback-state", "streaming"],
|
|
157
206
|
tokenSwapURL: "https://your-server.example.com/swap",
|
|
158
207
|
tokenRefreshURL: "https://your-server.example.com/refresh",
|
|
159
208
|
});
|
|
209
|
+
}
|
|
160
210
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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;
|
|
169
220
|
}
|
|
170
221
|
}
|
|
171
|
-
```
|
|
172
222
|
|
|
173
|
-
|
|
223
|
+
function NowPlaying() {
|
|
224
|
+
const session = useSession();
|
|
225
|
+
const connectionState = useConnectionState();
|
|
226
|
+
const track = useCurrentTrack();
|
|
227
|
+
const isPlaying = useIsPlaying();
|
|
174
228
|
|
|
175
|
-
|
|
176
|
-
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (session == null || connectionState !== "disconnected") return;
|
|
231
|
+
void connectRemote(session.accessToken);
|
|
232
|
+
}, [session, connectionState]);
|
|
177
233
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
});
|
|
182
|
-
```
|
|
234
|
+
if (connectionState !== "connected") {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
183
237
|
|
|
184
|
-
|
|
238
|
+
return (
|
|
239
|
+
<Text>
|
|
240
|
+
{track?.name ?? "No track"} · {isPlaying ? "Playing" : "Paused"}
|
|
241
|
+
</Text>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
```
|
|
185
245
|
|
|
186
|
-
|
|
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.
|
|
187
247
|
|
|
188
|
-
|
|
248
|
+
### Check account tier (Web API)
|
|
189
249
|
|
|
190
|
-
|
|
250
|
+
App Remote does not expose Premium status. Call Spotify's Web API with the access token from `Auth.authenticate()`:
|
|
191
251
|
|
|
192
|
-
|
|
252
|
+
```ts
|
|
253
|
+
const res = await fetch("https://api.spotify.com/v1/me", {
|
|
254
|
+
headers: { Authorization: `Bearer ${session.accessToken}` },
|
|
255
|
+
});
|
|
256
|
+
const profile = await res.json();
|
|
257
|
+
const isPremium = profile.product === "premium";
|
|
258
|
+
```
|
|
193
259
|
|
|
194
|
-
|
|
260
|
+
The [example app](./example) displays this as "Account tier: Premium / Free".
|
|
195
261
|
|
|
196
|
-
|
|
262
|
+
### Auto-connect App Remote
|
|
197
263
|
|
|
198
|
-
|
|
264
|
+
Avoid reconnect loops by gating on connection state and attempting at most once per session restore:
|
|
199
265
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
```
|
|
206
291
|
|
|
207
|
-
|
|
292
|
+
For iOS, if `connect()` fails with `CONNECTION_FAILED`, foreground the Spotify app and retry manually — see [Troubleshooting](#troubleshooting).
|
|
208
293
|
|
|
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). |
|
|
294
|
+
## Spotify Premium and App Remote
|
|
215
295
|
|
|
216
|
-
|
|
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` |
|
|
217
302
|
|
|
218
|
-
|
|
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.
|
|
219
304
|
|
|
220
|
-
|
|
305
|
+
## Migration from v0.x
|
|
221
306
|
|
|
222
|
-
|
|
307
|
+
v0.x top-level functions remain exported but are deprecated (removed in v2.0.0).
|
|
223
308
|
|
|
224
|
-
|
|
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` |
|
|
225
318
|
|
|
226
|
-
|
|
227
|
-
import {
|
|
228
|
-
authenticateAsync,
|
|
229
|
-
cancelPendingAuthAsync,
|
|
230
|
-
} from "@wwdrew/expo-spotify-sdk";
|
|
319
|
+
`SpotifySession`, `SpotifyScope`, and config shapes are unchanged. For auth-specific `e.code` narrowing, use `instanceof AuthError` instead of `instanceof SpotifyError`.
|
|
231
320
|
|
|
232
|
-
|
|
233
|
-
await cancelPendingAuthAsync();
|
|
234
|
-
return authenticateAsync({ scopes: ["user-read-email", "streaming"] });
|
|
235
|
-
}
|
|
236
|
-
```
|
|
321
|
+
## API reference
|
|
237
322
|
|
|
238
|
-
|
|
323
|
+
### `Auth`
|
|
239
324
|
|
|
240
|
-
|
|
325
|
+
#### `Auth.isAvailable(): boolean`
|
|
241
326
|
|
|
242
|
-
|
|
327
|
+
Returns `true` if the Spotify app is installed. Does not throw (not available on unsupported platforms).
|
|
243
328
|
|
|
244
|
-
|
|
329
|
+
#### `Auth.authenticate(config): Promise<SpotifySession>`
|
|
245
330
|
|
|
246
|
-
|
|
331
|
+
Starts OAuth via the installed Spotify app (or web fallback). Throws [`AuthError`](#autherror) on failure.
|
|
247
332
|
|
|
248
|
-
|
|
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`. |
|
|
249
339
|
|
|
250
|
-
|
|
|
251
|
-
|
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
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). |
|
|
255
346
|
|
|
256
|
-
|
|
347
|
+
#### `Auth.cancelPending(): Promise<void>`
|
|
257
348
|
|
|
258
|
-
|
|
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.
|
|
259
350
|
|
|
260
351
|
```ts
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
tokenRefreshURL: "https://your-server.example.com/refresh",
|
|
264
|
-
scopes: previous.scopes,
|
|
265
|
-
});
|
|
352
|
+
await Auth.cancelPending();
|
|
353
|
+
const session = await Auth.authenticate({ scopes: ["streaming"] });
|
|
266
354
|
```
|
|
267
355
|
|
|
268
|
-
|
|
356
|
+
A cancelled call rejects with `AuthError` `code: "USER_CANCELLED"`.
|
|
269
357
|
|
|
270
|
-
|
|
358
|
+
#### `Auth.refresh(config): Promise<SpotifySession>`
|
|
271
359
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
Returns a `Subscription` object; call `.remove()` to unsubscribe.
|
|
360
|
+
Exchanges a refresh token via your refresh server. Pass through `scopes` from the previous session when the refresh response omits `scope`.
|
|
275
361
|
|
|
276
362
|
```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
|
-
}
|
|
363
|
+
const refreshed = await Auth.refresh({
|
|
364
|
+
refreshToken: previous.refreshToken!,
|
|
365
|
+
tokenRefreshURL: "https://your-server.example.com/refresh",
|
|
366
|
+
scopes: previous.scopes,
|
|
289
367
|
});
|
|
290
|
-
|
|
291
|
-
// When the subscribing component unmounts:
|
|
292
|
-
sub.remove();
|
|
293
368
|
```
|
|
294
369
|
|
|
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 |
|
|
370
|
+
#### `Auth.addListener("sessionChange", listener): Subscription`
|
|
302
371
|
|
|
303
|
-
|
|
372
|
+
Central place to persist tokens. Fires for every `authenticate` / `refresh` — including calls you did not `await`.
|
|
304
373
|
|
|
305
|
-
|
|
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 |
|
|
306
379
|
|
|
307
|
-
|
|
380
|
+
#### `AuthError`
|
|
308
381
|
|
|
309
382
|
```ts
|
|
310
|
-
import {
|
|
383
|
+
import { Auth, AuthError } from "@wwdrew/expo-spotify-sdk";
|
|
311
384
|
|
|
312
385
|
try {
|
|
313
|
-
await
|
|
386
|
+
await Auth.authenticate({ scopes: ["streaming"] });
|
|
314
387
|
} catch (e) {
|
|
315
|
-
if (e instanceof
|
|
388
|
+
if (e instanceof AuthError) {
|
|
316
389
|
switch (e.code) {
|
|
317
|
-
case "USER_CANCELLED":
|
|
318
|
-
case "AUTH_IN_PROGRESS":
|
|
390
|
+
case "USER_CANCELLED":
|
|
391
|
+
case "AUTH_IN_PROGRESS":
|
|
319
392
|
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":
|
|
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":
|
|
327
400
|
reportError(e);
|
|
328
401
|
}
|
|
329
402
|
}
|
|
330
403
|
}
|
|
331
404
|
```
|
|
332
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
|
+
|
|
333
551
|
## Android implicit (TOKEN) flow is not recommended
|
|
334
552
|
|
|
335
|
-
When `
|
|
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:
|
|
336
554
|
|
|
337
555
|
1. **No `refreshToken`.** The Android SDK does not expose a refresh token for implicit grants. `session.refreshToken` will always be `null`.
|
|
338
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.
|
|
@@ -427,16 +645,38 @@ The `/swap` and `/refresh` API routes are served by the Expo dev server alongsid
|
|
|
427
645
|
**`INVALID_CONFIG: Missing meta-data 'spotifyClientId'`**
|
|
428
646
|
Run `expo prebuild` after adding the plugin to your config. The plugin injects the required `AndroidManifest.xml` entries.
|
|
429
647
|
|
|
430
|
-
**`isAvailable()` returns `false` on Android 11+ release builds**
|
|
648
|
+
**`Auth.isAvailable()` returns `false` on Android 11+ release builds**
|
|
431
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.
|
|
432
650
|
|
|
433
651
|
**iOS: authentication never returns**
|
|
434
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`.
|
|
435
653
|
|
|
436
654
|
**`AUTH_IN_PROGRESS`**
|
|
437
|
-
`
|
|
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
|
|
438
674
|
|
|
439
|
-
|
|
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
|
|
440
680
|
|
|
441
681
|
## Acknowledgements
|
|
442
682
|
|
package/android/build.gradle
CHANGED
|
@@ -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"
|