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