@wwdrew/expo-spotify-sdk 1.0.0 → 2.1.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 +92 -350
- package/android/build.gradle +2 -2
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +3 -5
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +6 -64
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteErrorMapping.kt +106 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +2 -7
- package/app.plugin.js +1 -1
- package/build/app-remote/index.d.ts +2 -2
- package/build/app-remote/index.d.ts.map +1 -1
- package/build/app-remote/index.js +11 -24
- package/build/app-remote/index.js.map +1 -1
- package/build/auth/index.d.ts +2 -2
- package/build/auth/index.d.ts.map +1 -1
- package/build/auth/index.js +17 -34
- package/build/auth/index.js.map +1 -1
- package/build/content/index.d.ts.map +1 -1
- package/build/content/index.js +12 -25
- package/build/content/index.js.map +1 -1
- package/build/hooks/index.d.ts +4 -4
- package/build/hooks/index.d.ts.map +1 -1
- package/build/hooks/index.js +38 -115
- package/build/hooks/index.js.map +1 -1
- package/build/images/index.d.ts +2 -2
- package/build/images/index.d.ts.map +1 -1
- package/build/images/index.js +12 -25
- package/build/images/index.js.map +1 -1
- package/build/index.d.ts +0 -18
- package/build/index.d.ts.map +1 -1
- package/build/index.js +0 -27
- package/build/index.js.map +1 -1
- package/build/internal/native-errors.d.ts +14 -0
- package/build/internal/native-errors.d.ts.map +1 -0
- package/build/internal/native-errors.js +29 -0
- package/build/internal/native-errors.js.map +1 -0
- package/build/internal/sync-external-store.d.ts +14 -0
- package/build/internal/sync-external-store.d.ts.map +1 -0
- package/build/internal/sync-external-store.js +36 -0
- package/build/internal/sync-external-store.js.map +1 -0
- package/build/player/index.d.ts +1 -1
- package/build/player/index.d.ts.map +1 -1
- package/build/player/index.js +15 -27
- package/build/player/index.js.map +1 -1
- package/build/uri/index.js.map +1 -1
- package/build/user/index.d.ts +2 -2
- package/build/user/index.d.ts.map +1 -1
- package/build/user/index.js +16 -26
- package/build/user/index.js.map +1 -1
- package/ios/ExpoSpotifySDK.podspec +1 -1
- package/ios/ExpoSpotifySDKModule.swift +1 -1
- package/ios/SpotifyAppRemoteCoordinator.swift +29 -404
- package/ios/SpotifyAppRemoteErrorMapping.swift +110 -0
- package/ios/SpotifyAppRemoteErrors.swift +221 -0
- package/ios/SpotifyAppRemoteMappers.swift +88 -0
- package/package.json +28 -14
- 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/plugin/index.d.ts +2 -0
- package/plugin/index.js +1 -0
package/README.md
CHANGED
|
@@ -8,33 +8,56 @@ An Expo module that wraps the native [Spotify iOS SDK](https://github.com/spotif
|
|
|
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
|
+
## Table of contents
|
|
12
|
+
|
|
13
|
+
- [Platform support](#platform-support)
|
|
14
|
+
- [Versioning and Expo SDK lanes](#versioning-and-expo-sdk-lanes)
|
|
15
|
+
- [Public API (Auth + App Remote)](#public-api-auth--app-remote)
|
|
16
|
+
- [Quick start (Expo)](#quick-start-expo)
|
|
17
|
+
- [Installation in bare React Native](#installation-in-bare-react-native)
|
|
18
|
+
- [Configuration](#configuration)
|
|
19
|
+
- [Usage](#usage)
|
|
20
|
+
- [Spotify Premium and App Remote](#spotify-premium-and-app-remote)
|
|
21
|
+
- [Migration from v0.x](#migration-from-v0x)
|
|
22
|
+
- [Documentation](#documentation)
|
|
23
|
+
- [Troubleshooting](#troubleshooting)
|
|
24
|
+
- [Related docs](#related-docs)
|
|
25
|
+
- [Acknowledgements](#acknowledgements)
|
|
26
|
+
- [Contributing](#contributing)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
11
29
|
## Platform support
|
|
12
30
|
|
|
13
31
|
**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
32
|
|
|
15
|
-
| Feature
|
|
16
|
-
|
|
|
17
|
-
| `Auth.*`
|
|
18
|
-
| `AppRemote.*` | ✅
|
|
19
|
-
| `Player.*`
|
|
20
|
-
| `User.*`
|
|
21
|
-
| `Content.*`
|
|
22
|
-
| `Images.*`
|
|
33
|
+
| Feature | iOS | Android |
|
|
34
|
+
| ------------- | --- | ------- |
|
|
35
|
+
| `Auth.*` | ✅ | ✅ |
|
|
36
|
+
| `AppRemote.*` | ✅ | ✅ |
|
|
37
|
+
| `Player.*` | ✅ | ✅ |
|
|
38
|
+
| `User.*` | ✅ | ✅ |
|
|
39
|
+
| `Content.*` | ✅ | ✅ |
|
|
40
|
+
| `Images.*` | ✅ | ✅ |
|
|
23
41
|
|
|
24
42
|
## Versioning and Expo SDK lanes
|
|
25
43
|
|
|
26
44
|
Install the major that matches your Expo SDK:
|
|
27
45
|
|
|
28
|
-
| npm version | Expo SDK | iOS minimum | Branch
|
|
29
|
-
|
|
|
30
|
-
| **`1.x`**
|
|
31
|
-
| **`2.x`**
|
|
46
|
+
| npm version | Expo SDK | iOS minimum | Branch |
|
|
47
|
+
| ----------- | -------- | ----------- | ----------------------------- |
|
|
48
|
+
| **`1.x`** | 55 | 15.1 | `v1` (long-lived maintenance) |
|
|
49
|
+
| **`2.x`** | 56+ | 16.4 | `main` |
|
|
32
50
|
|
|
33
51
|
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
52
|
|
|
35
|
-
The current `main` branch targets **Expo SDK
|
|
53
|
+
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)).
|
|
54
|
+
|
|
55
|
+
Auth payload note by lane:
|
|
36
56
|
|
|
37
|
-
|
|
57
|
+
- **`2.x` (`main`)**: Android token swap/refresh requests are normalized to match iOS (`code` for swap, `refresh_token` for refresh).
|
|
58
|
+
- **`1.x` (`v1`)**: Android keeps the legacy payload shape that includes additional form fields for compatibility with existing backends.
|
|
59
|
+
|
|
60
|
+
## Public API (Auth + App Remote)
|
|
38
61
|
|
|
39
62
|
```ts
|
|
40
63
|
import {
|
|
@@ -56,16 +79,18 @@ import {
|
|
|
56
79
|
} from "@wwdrew/expo-spotify-sdk";
|
|
57
80
|
```
|
|
58
81
|
|
|
59
|
-
Top-level v0-style functions
|
|
82
|
+
Top-level v0-style functions were removed in **`2.x`**. If you still use them, stay on **`1.x`** or migrate — see [Migration from v0.x](#migration-from-v0x).
|
|
60
83
|
|
|
61
84
|
**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.
|
|
62
85
|
|
|
63
86
|
## Quick start (Expo)
|
|
64
87
|
|
|
65
88
|
```sh
|
|
66
|
-
# 1. Install
|
|
89
|
+
# 1. Install (Expo SDK 56+ — matches `main` / npm `2.x`)
|
|
67
90
|
npx expo install @wwdrew/expo-spotify-sdk
|
|
68
91
|
|
|
92
|
+
# Expo SDK 55 only: npx expo install @wwdrew/expo-spotify-sdk@1
|
|
93
|
+
|
|
69
94
|
# 2. Add the config plugin to app.config.ts / app.json (see Configuration below)
|
|
70
95
|
# 3. Regenerate native projects
|
|
71
96
|
npx expo prebuild
|
|
@@ -137,7 +162,29 @@ Replace `myapp`, `spotify-auth`, and `your-spotify-client-id` with your own valu
|
|
|
137
162
|
|
|
138
163
|
## Configuration
|
|
139
164
|
|
|
140
|
-
Add the plugin to your `app.config.ts` (or `app.json`)
|
|
165
|
+
Add the plugin to your `app.config.ts` (or `app.json`).
|
|
166
|
+
|
|
167
|
+
### Typed plugin (Expo SDK 56+)
|
|
168
|
+
|
|
169
|
+
Import from `@wwdrew/expo-spotify-sdk/plugin` for autocomplete and type-checked options:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import type { ExpoConfig } from "expo/config";
|
|
173
|
+
import withSpotifySdk from "@wwdrew/expo-spotify-sdk/plugin";
|
|
174
|
+
|
|
175
|
+
export default ({ config }: { config: ExpoConfig }): ExpoConfig => ({
|
|
176
|
+
...config,
|
|
177
|
+
plugins: [
|
|
178
|
+
withSpotifySdk({
|
|
179
|
+
clientID: "your-spotify-client-id",
|
|
180
|
+
scheme: "myapp",
|
|
181
|
+
host: "spotify-auth",
|
|
182
|
+
}),
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### String tuple (all SDK versions)
|
|
141
188
|
|
|
142
189
|
```ts
|
|
143
190
|
export default {
|
|
@@ -265,7 +312,11 @@ Avoid reconnect loops by gating on connection state and attempting at most once
|
|
|
265
312
|
|
|
266
313
|
```ts
|
|
267
314
|
import { useEffect, useRef } from "react";
|
|
268
|
-
import {
|
|
315
|
+
import {
|
|
316
|
+
AppRemote,
|
|
317
|
+
useConnectionState,
|
|
318
|
+
useSession,
|
|
319
|
+
} from "@wwdrew/expo-spotify-sdk";
|
|
269
320
|
|
|
270
321
|
/**
|
|
271
322
|
* Connect when a session exists and App Remote is disconnected.
|
|
@@ -293,20 +344,20 @@ For iOS, if `connect()` fails with `CONNECTION_FAILED`, foreground the Spotify a
|
|
|
293
344
|
|
|
294
345
|
## Spotify Premium and App Remote
|
|
295
346
|
|
|
296
|
-
| Concern
|
|
297
|
-
|
|
|
298
|
-
| Spotify app installed | Recommended (native UX) | **Required**
|
|
299
|
-
| Spotify app running
|
|
300
|
-
| Spotify Premium
|
|
301
|
-
| Free account
|
|
347
|
+
| Concern | Auth (`Auth.*`) | App Remote (`AppRemote.*`, `Player.*`, …) |
|
|
348
|
+
| --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
349
|
+
| Spotify app installed | Recommended (native UX) | **Required** |
|
|
350
|
+
| Spotify app running | No | **Required** — connect talks to the running Spotify process over IPC |
|
|
351
|
+
| Spotify Premium | No | **Required** for reliable on-demand playback and rich player state (track name, transport, browse) |
|
|
352
|
+
| Free account | Auth works | `Player.play()` may fail with `PREMIUM_REQUIRED`; `User.getCapabilities().canPlayOnDemand` is `false` |
|
|
302
353
|
|
|
303
354
|
**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.
|
|
304
355
|
|
|
305
356
|
## Migration from v0.x
|
|
306
357
|
|
|
307
|
-
|
|
358
|
+
**Removed in `2.x`.** The flat top-level functions (`authenticateAsync`, `isAvailable`, `refreshSessionAsync`, `cancelPendingAuthAsync`, `addSessionChangeListener`) and legacy type aliases (`SpotifyConfig`, `SpotifyErrorCode`) are no longer exported on the **`2.x`** lane.
|
|
308
359
|
|
|
309
|
-
| v0.x |
|
|
360
|
+
| v0.x | v2 |
|
|
310
361
|
| --- | --- |
|
|
311
362
|
| `isAvailable()` | `Auth.isAvailable()` |
|
|
312
363
|
| `authenticateAsync(config)` | `Auth.authenticate(config)` |
|
|
@@ -316,329 +367,19 @@ v0.x top-level functions remain exported but are deprecated (removed in v2.0.0).
|
|
|
316
367
|
| `SpotifyError` (auth throws) | `AuthError` (or `instanceof SpotifyError` as catch-all) |
|
|
317
368
|
| `SpotifyErrorCode` | `AuthErrorCode` |
|
|
318
369
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
## API reference
|
|
322
|
-
|
|
323
|
-
### `Auth`
|
|
324
|
-
|
|
325
|
-
#### `Auth.isAvailable(): boolean`
|
|
326
|
-
|
|
327
|
-
Returns `true` if the Spotify app is installed. Does not throw (not available on unsupported platforms).
|
|
328
|
-
|
|
329
|
-
#### `Auth.authenticate(config): Promise<SpotifySession>`
|
|
330
|
-
|
|
331
|
-
Starts OAuth via the installed Spotify app (or web fallback). Throws [`AuthError`](#autherror) on failure.
|
|
332
|
-
|
|
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`. |
|
|
339
|
-
|
|
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). |
|
|
346
|
-
|
|
347
|
-
#### `Auth.cancelPending(): Promise<void>`
|
|
370
|
+
Need the deprecated shims temporarily? Pin **`1.x`**: `npm install @wwdrew/expo-spotify-sdk@1`.
|
|
348
371
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
```ts
|
|
352
|
-
await Auth.cancelPending();
|
|
353
|
-
const session = await Auth.authenticate({ scopes: ["streaming"] });
|
|
354
|
-
```
|
|
355
|
-
|
|
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
|
-
```
|
|
369
|
-
|
|
370
|
-
#### `Auth.addListener("sessionChange", listener): Subscription`
|
|
371
|
-
|
|
372
|
-
Central place to persist tokens. Fires for every `authenticate` / `refresh` — including calls you did not `await`.
|
|
373
|
-
|
|
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 |
|
|
379
|
-
|
|
380
|
-
#### `AuthError`
|
|
381
|
-
|
|
382
|
-
```ts
|
|
383
|
-
import { Auth, AuthError } from "@wwdrew/expo-spotify-sdk";
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
await Auth.authenticate({ scopes: ["streaming"] });
|
|
387
|
-
} catch (e) {
|
|
388
|
-
if (e instanceof AuthError) {
|
|
389
|
-
switch (e.code) {
|
|
390
|
-
case "USER_CANCELLED":
|
|
391
|
-
case "AUTH_IN_PROGRESS":
|
|
392
|
-
return;
|
|
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":
|
|
400
|
-
reportError(e);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
```
|
|
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`
|
|
372
|
+
`SpotifySession`, `SpotifyScope`, and config shapes are unchanged. For auth-specific `e.code` narrowing, use `instanceof AuthError` instead of `instanceof SpotifyError`.
|
|
409
373
|
|
|
410
|
-
|
|
374
|
+
## Documentation
|
|
411
375
|
|
|
412
|
-
|
|
|
376
|
+
| Guide | Contents |
|
|
413
377
|
| --- | --- |
|
|
414
|
-
|
|
|
415
|
-
|
|
|
416
|
-
|
|
|
417
|
-
|
|
|
418
|
-
|
|
|
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
|
-
|
|
551
|
-
## Android implicit (TOKEN) flow is not recommended
|
|
552
|
-
|
|
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:
|
|
554
|
-
|
|
555
|
-
1. **No `refreshToken`.** The Android SDK does not expose a refresh token for implicit grants. `session.refreshToken` will always be `null`.
|
|
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.
|
|
557
|
-
|
|
558
|
-
The library emits a one-time `console.warn` at runtime when this path is taken.
|
|
559
|
-
|
|
560
|
-
**The fix:** provide a `tokenSwapURL` to use the Authorization Code flow, which returns a full `refreshToken` and the actual granted `scopes` on both platforms.
|
|
561
|
-
|
|
562
|
-
See [Spotify's migration guide](https://developer.spotify.com/documentation/android/tutorials/migration-token-code) for context, and the [token swap server section](#token-swap-server) below for a reference implementation.
|
|
563
|
-
|
|
564
|
-
## Token swap server
|
|
565
|
-
|
|
566
|
-
The `tokenSwapURL` / `tokenRefreshURL` endpoints must be a server you control — **never** put your Spotify `CLIENT_SECRET` in the app bundle.
|
|
567
|
-
|
|
568
|
-
### Swap endpoint (`POST {tokenSwapURL}`)
|
|
569
|
-
|
|
570
|
-
The native module sends a `application/x-www-form-urlencoded` body:
|
|
571
|
-
|
|
572
|
-
```
|
|
573
|
-
code=<authorization-code>&redirect_uri=<redirect-uri>&client_id=<client-id>
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
Your server POSTs these to `https://accounts.spotify.com/api/token` with `grant_type=authorization_code` and your `CLIENT_SECRET` in the `Authorization` header, then returns Spotify's response verbatim as `application/json`:
|
|
577
|
-
|
|
578
|
-
```json
|
|
579
|
-
{
|
|
580
|
-
"access_token": "BQA...",
|
|
581
|
-
"token_type": "Bearer",
|
|
582
|
-
"expires_in": 3600,
|
|
583
|
-
"refresh_token": "AQA...",
|
|
584
|
-
"scope": "user-read-email streaming"
|
|
585
|
-
}
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
### Refresh endpoint (`POST {tokenRefreshURL}`)
|
|
589
|
-
|
|
590
|
-
The native module sends:
|
|
591
|
-
|
|
592
|
-
```
|
|
593
|
-
refresh_token=<token>&client_id=<client-id>
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
Your server POSTs to `https://accounts.spotify.com/api/token` with `grant_type=refresh_token`. Return Spotify's response verbatim. If Spotify rotates the refresh token the response will contain a new `refresh_token`; if not, the field is absent — the library handles both cases correctly.
|
|
597
|
-
|
|
598
|
-
### Error responses
|
|
599
|
-
|
|
600
|
-
Return a non-2xx HTTP status with a JSON body for structured error propagation. The library will reject with `TOKEN_SWAP_FAILED` and include the status code and (truncated) response body in `e.message`.
|
|
601
|
-
|
|
602
|
-
### Reference implementation
|
|
603
|
-
|
|
604
|
-
The example app uses [Expo Router API routes](https://docs.expo.dev/router/reference/api-routes/) for the swap and refresh endpoints — no separate server process needed.
|
|
605
|
-
|
|
606
|
-
**Before running the example, you need a Spotify app:**
|
|
607
|
-
|
|
608
|
-
> **Note (February 2026 onwards):** Spotify now requires the app owner to have an active **Spotify Premium** subscription to use Development Mode apps. Development Mode apps are also limited to **5 test users** — each user must be explicitly added in your Dashboard under **User Management**. See [Spotify's February 2026 migration guide](https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide) for details.
|
|
609
|
-
|
|
610
|
-
1. Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and create an app (or use an existing one).
|
|
611
|
-
2. In the app settings, under **APIs used**, enable **Web API** (required for the `/v1/me` profile call).
|
|
612
|
-
3. Under **Redirect URIs**, add `expo-spotify-sdk-example://authenticate` exactly and save.
|
|
613
|
-
4. Under **User Management**, add the Spotify accounts that will test the app (up to 5 in Development Mode).
|
|
614
|
-
|
|
615
|
-
**Then populate the credentials:**
|
|
616
|
-
|
|
617
|
-
```sh
|
|
618
|
-
cd example
|
|
619
|
-
cp .env.local.example .env.local
|
|
620
|
-
```
|
|
621
|
-
|
|
622
|
-
Edit `.env.local` — all three values are required:
|
|
623
|
-
|
|
624
|
-
```sh
|
|
625
|
-
# From your Spotify app's dashboard page
|
|
626
|
-
SPOTIFY_CLIENT_ID=your_client_id_here
|
|
627
|
-
SPOTIFY_CLIENT_SECRET=your_client_secret_here
|
|
628
|
-
|
|
629
|
-
# Must match the redirect URI you registered in the Spotify Dashboard
|
|
630
|
-
SPOTIFY_REDIRECT_URI=expo-spotify-sdk-example://authenticate
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
`SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` are shown on your app's dashboard page. `SPOTIFY_CLIENT_SECRET` is revealed by clicking **View client secret**.
|
|
634
|
-
|
|
635
|
-
**Then run the app:**
|
|
636
|
-
|
|
637
|
-
```sh
|
|
638
|
-
npx expo start
|
|
639
|
-
```
|
|
640
|
-
|
|
641
|
-
The `/swap` and `/refresh` API routes are served by the Expo dev server alongside the app. `Constants.expoConfig.hostUri` auto-detects the correct server URL for any device or simulator — no manual IP configuration needed.
|
|
378
|
+
| [API reference](./docs/api-reference.md) | All namespaces, methods, hooks |
|
|
379
|
+
| [Error codes](./docs/error-codes.md) | Per-namespace codes with when/what-to-do |
|
|
380
|
+
| [App Remote error mapping](./docs/app-remote-error-mapping.md) | iOS/Android native → JS mapping matrix |
|
|
381
|
+
| [Platform differences](./docs/guides/platform-differences.md) | iOS vs Android parity |
|
|
382
|
+
| [Token swap server](./docs/guides/token-swap-server.md) | Swap/refresh endpoints + example app setup |
|
|
642
383
|
|
|
643
384
|
## Troubleshooting
|
|
644
385
|
|
|
@@ -654,7 +395,7 @@ Ensure your app's URL scheme is registered in Xcode under **Info → URL Types**
|
|
|
654
395
|
**`AUTH_IN_PROGRESS`**
|
|
655
396
|
`Auth.authenticate()` was called while a previous call was still pending. Usually a concurrent call — wait for the first to settle.
|
|
656
397
|
|
|
657
|
-
On iOS this can also be a stuck state when Spotify never redirects back. Call
|
|
398
|
+
On iOS this can also be a stuck state when Spotify never redirects back. Call `Auth.cancelPending()` before retrying.
|
|
658
399
|
|
|
659
400
|
**App Remote: `CONNECTION_FAILED` / `Connection refused` (iOS code 61)**
|
|
660
401
|
The Spotify app is installed but its App Remote transport is not accepting connections. Common causes:
|
|
@@ -665,7 +406,7 @@ The Spotify app is installed but its App Remote transport is not accepting conne
|
|
|
665
406
|
4. **Retry loop on startup** — avoid calling `connect()` on every render while `connectionState === "disconnected"`; gate on a one-shot flag or user action.
|
|
666
407
|
|
|
667
408
|
**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 [
|
|
409
|
+
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 [Platform differences](./docs/guides/platform-differences.md)).
|
|
669
410
|
|
|
670
411
|
**Now playing shows no track title (Android, Free account)**
|
|
671
412
|
App Remote player state is limited for non-Premium users. Check `GET /v1/me` → `product` and `User.getCapabilities().canPlayOnDemand`.
|
|
@@ -673,9 +414,10 @@ App Remote player state is limited for non-Premium users. Check `GET /v1/me` →
|
|
|
673
414
|
## Related docs
|
|
674
415
|
|
|
675
416
|
- [CONTEXT.md](./CONTEXT.md) — terminology (Auth SDK vs App Remote vs Web API)
|
|
676
|
-
- [docs/
|
|
677
|
-
- [docs/
|
|
678
|
-
- [docs/
|
|
417
|
+
- [docs/api-reference.md](./docs/api-reference.md) — method reference
|
|
418
|
+
- [docs/error-codes.md](./docs/error-codes.md) — error code tables
|
|
419
|
+
- [docs/QA_CHECKLIST.md](./docs/QA_CHECKLIST.md) — manual QA before a `2.x` release on `main` (or `1.x` on `v1`)
|
|
420
|
+
- [docs/RELEASE.md](./docs/RELEASE.md) — Release Please on `main` (`2.x`); maintenance releases from `v1` (`1.x`)
|
|
679
421
|
- [ATTRIBUTION.md](./ATTRIBUTION.md) — third-party SDKs and scope boundaries
|
|
680
422
|
|
|
681
423
|
## Acknowledgements
|
package/android/build.gradle
CHANGED
|
@@ -16,10 +16,10 @@ buildscript {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
project.android {
|
|
19
|
-
compileSdkVersion safeExtGet("compileSdkVersion",
|
|
19
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
|
20
20
|
defaultConfig {
|
|
21
21
|
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
22
|
-
targetSdkVersion safeExtGet("targetSdkVersion",
|
|
22
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
23
23
|
}
|
|
24
24
|
compileOptions {
|
|
25
25
|
sourceCompatibility JavaVersion.VERSION_17
|
|
@@ -8,7 +8,7 @@ import expo.modules.kotlin.functions.Coroutine
|
|
|
8
8
|
import expo.modules.kotlin.modules.Module
|
|
9
9
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
10
10
|
|
|
11
|
-
private const val SDK_VERSION = "1.0
|
|
11
|
+
private const val SDK_VERSION = "2.1.0" // x-release-please-version
|
|
12
12
|
private const val EVENT_SESSION_CHANGE = "onSessionChange"
|
|
13
13
|
private const val EVENT_CONNECTION_STATE_CHANGE = "onConnectionStateChange"
|
|
14
14
|
private const val EVENT_CONNECTION_ERROR = "onConnectionError"
|
|
@@ -141,11 +141,10 @@ class ExpoSpotifySDKModule : Module() {
|
|
|
141
141
|
?: throw InvalidConfigException(
|
|
142
142
|
"Received CODE response but no tokenSwapURL was configured",
|
|
143
143
|
)
|
|
144
|
-
val client = SpotifyTokenSwapClient(SDK_VERSION
|
|
144
|
+
val client = SpotifyTokenSwapClient(SDK_VERSION)
|
|
145
145
|
client.swap(
|
|
146
146
|
code = response.code
|
|
147
147
|
?: throw TokenSwapParseException("Spotify returned CODE without a code"),
|
|
148
|
-
redirectUri = manifest.redirectUri,
|
|
149
148
|
tokenSwapURL = swapURL,
|
|
150
149
|
requestedScopes = config.scopes,
|
|
151
150
|
)
|
|
@@ -177,8 +176,7 @@ class ExpoSpotifySDKModule : Module() {
|
|
|
177
176
|
if (options.tokenRefreshURL.isBlank()) {
|
|
178
177
|
throw InvalidConfigException("`tokenRefreshURL` is required")
|
|
179
178
|
}
|
|
180
|
-
val
|
|
181
|
-
val client = SpotifyTokenSwapClient(SDK_VERSION, manifest.clientId)
|
|
179
|
+
val client = SpotifyTokenSwapClient(SDK_VERSION)
|
|
182
180
|
val payload = client.refresh(
|
|
183
181
|
refreshToken = options.refreshToken,
|
|
184
182
|
tokenRefreshURL = options.tokenRefreshURL,
|