@wwdrew/expo-spotify-sdk 2.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 +62 -344
- 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/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.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 +1 -1
- 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.map +1 -1
- package/build/player/index.js +14 -27
- package/build/player/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/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 +4 -2
- package/plugin/index.d.ts +2 -0
- package/plugin/index.js +1 -0
package/README.md
CHANGED
|
@@ -8,32 +8,55 @@ 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
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)).
|
|
36
54
|
|
|
55
|
+
Auth payload note by lane:
|
|
56
|
+
|
|
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
|
+
|
|
37
60
|
## Public API (Auth + App Remote)
|
|
38
61
|
|
|
39
62
|
```ts
|
|
@@ -56,7 +79,7 @@ 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
|
|
|
@@ -289,7 +312,11 @@ Avoid reconnect loops by gating on connection state and attempting at most once
|
|
|
289
312
|
|
|
290
313
|
```ts
|
|
291
314
|
import { useEffect, useRef } from "react";
|
|
292
|
-
import {
|
|
315
|
+
import {
|
|
316
|
+
AppRemote,
|
|
317
|
+
useConnectionState,
|
|
318
|
+
useSession,
|
|
319
|
+
} from "@wwdrew/expo-spotify-sdk";
|
|
293
320
|
|
|
294
321
|
/**
|
|
295
322
|
* Connect when a session exists and App Remote is disconnected.
|
|
@@ -317,20 +344,20 @@ For iOS, if `connect()` fails with `CONNECTION_FAILED`, foreground the Spotify a
|
|
|
317
344
|
|
|
318
345
|
## Spotify Premium and App Remote
|
|
319
346
|
|
|
320
|
-
| Concern
|
|
321
|
-
|
|
|
322
|
-
| Spotify app installed | Recommended (native UX) | **Required**
|
|
323
|
-
| Spotify app running
|
|
324
|
-
| Spotify Premium
|
|
325
|
-
| 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` |
|
|
326
353
|
|
|
327
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.
|
|
328
355
|
|
|
329
356
|
## Migration from v0.x
|
|
330
357
|
|
|
331
|
-
|
|
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.
|
|
332
359
|
|
|
333
|
-
| v0.x |
|
|
360
|
+
| v0.x | v2 |
|
|
334
361
|
| --- | --- |
|
|
335
362
|
| `isAvailable()` | `Auth.isAvailable()` |
|
|
336
363
|
| `authenticateAsync(config)` | `Auth.authenticate(config)` |
|
|
@@ -340,329 +367,19 @@ v0.x top-level functions remain exported but are deprecated (scheduled for remov
|
|
|
340
367
|
| `SpotifyError` (auth throws) | `AuthError` (or `instanceof SpotifyError` as catch-all) |
|
|
341
368
|
| `SpotifyErrorCode` | `AuthErrorCode` |
|
|
342
369
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
## API reference
|
|
346
|
-
|
|
347
|
-
### `Auth`
|
|
348
|
-
|
|
349
|
-
#### `Auth.isAvailable(): boolean`
|
|
350
|
-
|
|
351
|
-
Returns `true` if the Spotify app is installed. Does not throw (not available on unsupported platforms).
|
|
352
|
-
|
|
353
|
-
#### `Auth.authenticate(config): Promise<SpotifySession>`
|
|
370
|
+
Need the deprecated shims temporarily? Pin **`1.x`**: `npm install @wwdrew/expo-spotify-sdk@1`.
|
|
354
371
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
| Field | Type | Required | Description |
|
|
358
|
-
| --- | --- | --- | --- |
|
|
359
|
-
| `scopes` | `SpotifyScope[]` | ✅ | At least one scope. Include `app-remote-control` for App Remote. |
|
|
360
|
-
| `tokenSwapURL` | `string` | — | Code flow + server swap (recommended). Required on Android for a `refreshToken`. |
|
|
361
|
-
| `tokenRefreshURL` | `string` | — | Refresh endpoint for iOS SDK and `Auth.refresh()`. |
|
|
362
|
-
| `showDialog` | `boolean` | — | Force the consent screen. Default `false`. |
|
|
363
|
-
|
|
364
|
-
| Return field | Type | Description |
|
|
365
|
-
| --- | --- | --- |
|
|
366
|
-
| `accessToken` | `string` | Bearer token for Web API and `AppRemote.connect()`. |
|
|
367
|
-
| `refreshToken` | `string \| null` | `null` on Android without `tokenSwapURL`. |
|
|
368
|
-
| `expirationDate` | `number` | Expiry as Unix epoch **milliseconds**. |
|
|
369
|
-
| `scopes` | `SpotifyScope[]` | Granted scopes (requested-only on Android TOKEN flow). |
|
|
370
|
-
|
|
371
|
-
#### `Auth.cancelPending(): Promise<void>`
|
|
372
|
-
|
|
373
|
-
Cancels an in-flight `Auth.authenticate()`. On iOS, call before retrying if you see `AUTH_IN_PROGRESS` after a dropped redirect. No-op on Android.
|
|
374
|
-
|
|
375
|
-
```ts
|
|
376
|
-
await Auth.cancelPending();
|
|
377
|
-
const session = await Auth.authenticate({ scopes: ["streaming"] });
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
A cancelled call rejects with `AuthError` `code: "USER_CANCELLED"`.
|
|
381
|
-
|
|
382
|
-
#### `Auth.refresh(config): Promise<SpotifySession>`
|
|
383
|
-
|
|
384
|
-
Exchanges a refresh token via your refresh server. Pass through `scopes` from the previous session when the refresh response omits `scope`.
|
|
385
|
-
|
|
386
|
-
```ts
|
|
387
|
-
const refreshed = await Auth.refresh({
|
|
388
|
-
refreshToken: previous.refreshToken!,
|
|
389
|
-
tokenRefreshURL: "https://your-server.example.com/refresh",
|
|
390
|
-
scopes: previous.scopes,
|
|
391
|
-
});
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
#### `Auth.addListener("sessionChange", listener): Subscription`
|
|
395
|
-
|
|
396
|
-
Central place to persist tokens. Fires for every `authenticate` / `refresh` — including calls you did not `await`.
|
|
397
|
-
|
|
398
|
-
| `type` | Payload | When |
|
|
399
|
-
| --- | --- | --- |
|
|
400
|
-
| `"didInitiate"` | `{ session }` | `Auth.authenticate()` succeeded |
|
|
401
|
-
| `"didRenew"` | `{ session }` | `Auth.refresh()` succeeded |
|
|
402
|
-
| `"didFail"` | `{ error: { code, message } }` | Either call failed |
|
|
403
|
-
|
|
404
|
-
#### `AuthError`
|
|
405
|
-
|
|
406
|
-
```ts
|
|
407
|
-
import { Auth, AuthError } from "@wwdrew/expo-spotify-sdk";
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
await Auth.authenticate({ scopes: ["streaming"] });
|
|
411
|
-
} catch (e) {
|
|
412
|
-
if (e instanceof AuthError) {
|
|
413
|
-
switch (e.code) {
|
|
414
|
-
case "USER_CANCELLED":
|
|
415
|
-
case "AUTH_IN_PROGRESS":
|
|
416
|
-
return;
|
|
417
|
-
case "INVALID_CONFIG":
|
|
418
|
-
case "NETWORK_ERROR":
|
|
419
|
-
case "TOKEN_SWAP_FAILED":
|
|
420
|
-
case "TOKEN_SWAP_PARSE_ERROR":
|
|
421
|
-
case "SPOTIFY_NOT_INSTALLED":
|
|
422
|
-
case "AUTH_ERROR":
|
|
423
|
-
case "UNKNOWN":
|
|
424
|
-
reportError(e);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
`e.message` is the native reason string. On iOS `UNKNOWN` auth failures, the message includes the full `NSError` chain (e.g. `NSURLErrorDomain` to your `tokenSwapURL`). `e.cause` retains the original error for logging.
|
|
431
|
-
|
|
432
|
-
### `AppRemote`
|
|
372
|
+
`SpotifySession`, `SpotifyScope`, and config shapes are unchanged. For auth-specific `e.code` narrowing, use `instanceof AuthError` instead of `instanceof SpotifyError`.
|
|
433
373
|
|
|
434
|
-
|
|
374
|
+
## Documentation
|
|
435
375
|
|
|
436
|
-
|
|
|
376
|
+
| Guide | Contents |
|
|
437
377
|
| --- | --- |
|
|
438
|
-
|
|
|
439
|
-
|
|
|
440
|
-
|
|
|
441
|
-
|
|
|
442
|
-
|
|
|
443
|
-
| `addListener("connectionError", cb)` | Failures and drops (`AppRemoteError` codes). |
|
|
444
|
-
|
|
445
|
-
**Platform notes:**
|
|
446
|
-
|
|
447
|
-
- **iOS:** pass the access token from `Auth.authenticate()`. If connect fails with `CONNECTION_FAILED` / `Connection refused` (`NSPOSIXErrorDomain` 61), bring Spotify to the foreground and retry — backgrounded Spotify often has no transport listener yet.
|
|
448
|
-
- **Android:** `accessToken` is accepted for API parity; the SDK uses the session cached in the Spotify app from your prior `Auth.authenticate()` call.
|
|
449
|
-
|
|
450
|
-
Calling `connect()` while already connected is a no-op.
|
|
451
|
-
|
|
452
|
-
### `Player`
|
|
453
|
-
|
|
454
|
-
- `Player.play(uri)`
|
|
455
|
-
- `Player.pause()`
|
|
456
|
-
- `Player.resume()`
|
|
457
|
-
- `Player.skipNext()`
|
|
458
|
-
- `Player.skipPrevious()`
|
|
459
|
-
- `Player.seekTo(positionMs)`
|
|
460
|
-
- `Player.setShuffle(enabled)`
|
|
461
|
-
- `Player.setRepeatMode(mode)`
|
|
462
|
-
- `Player.setPodcastPlaybackSpeed(speed)`
|
|
463
|
-
- `Player.queue(uri)`
|
|
464
|
-
- `Player.getPlayerState()`
|
|
465
|
-
- `Player.getCrossfadeState()`
|
|
466
|
-
- `Player.addListener("playerStateChange", cb)`
|
|
467
|
-
|
|
468
|
-
### `User`
|
|
469
|
-
|
|
470
|
-
- `User.getCapabilities()`
|
|
471
|
-
- `User.getLibraryState(uri)`
|
|
472
|
-
- `User.addToLibrary(uri)`
|
|
473
|
-
- `User.removeFromLibrary(uri)`
|
|
474
|
-
- `User.addListener("capabilitiesChange", cb)`
|
|
475
|
-
- `User.addLibraryStateListener(uri, cb)`
|
|
476
|
-
|
|
477
|
-
### `Content`
|
|
478
|
-
|
|
479
|
-
- `Content.getRecommendedContentItems(type)`
|
|
480
|
-
- `Content.getChildren(item)`
|
|
481
|
-
|
|
482
|
-
### `Images`
|
|
483
|
-
|
|
484
|
-
- `Images.load(item, size)`
|
|
485
|
-
|
|
486
|
-
### Hooks
|
|
487
|
-
|
|
488
|
-
Built on `useSyncExternalStore` over the native event streams. Subscribe in any component; no manual `addListener` cleanup required.
|
|
489
|
-
|
|
490
|
-
- `useSession()` — latest `SpotifySession` from auth events (or `null`)
|
|
491
|
-
- `useConnectionState()` — `disconnected` \| `connecting` \| `connected`
|
|
492
|
-
- `usePlayerState()` — full player snapshot
|
|
493
|
-
- `useCurrentTrack()` / `useIsPlaying()` / `usePlaybackPosition()` — derived from player state
|
|
494
|
-
- `useCapabilities()` — `canPlayOnDemand` and related flags
|
|
495
|
-
- `useLibraryState(uri)` — save state for one URI
|
|
496
|
-
|
|
497
|
-
## Error codes by namespace
|
|
498
|
-
|
|
499
|
-
Every rejection is an instance of a namespace-specific subclass (`AuthError`, `AppRemoteError`, …) extending the abstract `SpotifyError` base. Catch with `instanceof` for typed `code` narrowing.
|
|
500
|
-
|
|
501
|
-
### `AuthErrorCode`
|
|
502
|
-
|
|
503
|
-
| Code | When | What to do |
|
|
504
|
-
| --- | --- | --- |
|
|
505
|
-
| `USER_CANCELLED` | User closed auth or `Auth.cancelPending()` ran | Benign — no action |
|
|
506
|
-
| `AUTH_IN_PROGRESS` | Concurrent `Auth.authenticate()` or iOS stuck pending auth | `await Auth.cancelPending()` then retry |
|
|
507
|
-
| `INVALID_CONFIG` | Missing `clientID`, empty `scopes`, or bad plugin setup | Fix config plugin / `app.config`; run `expo prebuild` |
|
|
508
|
-
| `NETWORK_ERROR` | Device cannot reach `tokenSwapURL` / `tokenRefreshURL` | Check dev server URL, HTTPS, device network |
|
|
509
|
-
| `TOKEN_SWAP_FAILED` | Swap server returned non-2xx | Fix server; read status + body in `e.message` |
|
|
510
|
-
| `TOKEN_SWAP_PARSE_ERROR` | Swap response was not valid token JSON | Fix server response shape |
|
|
511
|
-
| `SPOTIFY_NOT_INSTALLED` | Spotify app not found (rare — web fallback may still run) | Prompt install or use web auth |
|
|
512
|
-
| `AUTH_ERROR` | Spotify rejected the authorization | Check Dashboard redirect URI, scopes, test users |
|
|
513
|
-
| `UNKNOWN` | Unexpected native failure | Read `e.message` / `e.cause`; file an issue with logs |
|
|
514
|
-
|
|
515
|
-
### `AppRemoteErrorCode`
|
|
516
|
-
|
|
517
|
-
| Code | When | What to do |
|
|
518
|
-
| --- | --- | --- |
|
|
519
|
-
| `CONNECTION_FAILED` | `connect()` failed (app missing, refused, handshake error) | Open Spotify foreground; re-auth if token stale; retry once |
|
|
520
|
-
| `CONNECTION_LOST` | Connection dropped mid-session | `AppRemote.connect()` again with fresh token |
|
|
521
|
-
| `NOT_CONNECTED` | `AppRemote.*` called before connect completed | `await AppRemote.connect()` first |
|
|
522
|
-
| `UNKNOWN` | Unexpected IPC failure | Read `e.message`; retry connect |
|
|
523
|
-
|
|
524
|
-
### `PlayerErrorCode`
|
|
525
|
-
|
|
526
|
-
| Code | When | What to do |
|
|
527
|
-
| --- | --- | --- |
|
|
528
|
-
| `NOT_CONNECTED` | `Player.*` before `AppRemote.connect()` | Connect App Remote first |
|
|
529
|
-
| `CONNECTION_LOST` | Dropped during a player call | Reconnect, then retry |
|
|
530
|
-
| `PREMIUM_REQUIRED` | `canPlayOnDemand === false` (Free tier) | Upgrade account or use shuffle/context play only |
|
|
531
|
-
| `INVALID_URI` | URI failed validation | Use `SpotifyURI.from()` |
|
|
532
|
-
| `INVALID_PARAMETER` | Bad argument (e.g. negative seek) | Fix caller |
|
|
533
|
-
| `OPERATION_NOT_ALLOWED` | Player restriction (can't skip, etc.) | Check `PlayerState` restrictions |
|
|
534
|
-
| `UNKNOWN` | Other native player error | Read `e.message` |
|
|
535
|
-
|
|
536
|
-
### `UserErrorCode`
|
|
537
|
-
|
|
538
|
-
| Code | When | What to do |
|
|
539
|
-
| --- | --- | --- |
|
|
540
|
-
| `NOT_CONNECTED` | `User.*` before connect | Connect App Remote first |
|
|
541
|
-
| `CONNECTION_LOST` | Dropped during user API call | Reconnect |
|
|
542
|
-
| `INVALID_URI` | Bad library URI | Use `SpotifyURI.from()` |
|
|
543
|
-
| `OPERATION_NOT_ALLOWED` | Save/remove blocked (tier, region) | Check `LibraryState.canAdd` / capabilities |
|
|
544
|
-
| `UNKNOWN` | Other native user error | Read `e.message` |
|
|
545
|
-
|
|
546
|
-
### `ContentErrorCode`
|
|
547
|
-
|
|
548
|
-
| Code | When | What to do |
|
|
549
|
-
| --- | --- | --- |
|
|
550
|
-
| `NOT_CONNECTED` | `Content.*` before connect | Connect App Remote first |
|
|
551
|
-
| `CONNECTION_LOST` | Dropped during browse | Reconnect |
|
|
552
|
-
| `CONTENT_API_UNAVAILABLE` | Spotify app too old for Content API | Update Spotify app |
|
|
553
|
-
| `UNKNOWN` | Other content error | Read `e.message` |
|
|
554
|
-
|
|
555
|
-
### `ImagesErrorCode`
|
|
556
|
-
|
|
557
|
-
| Code | When | What to do |
|
|
558
|
-
| --- | --- | --- |
|
|
559
|
-
| `NOT_CONNECTED` | `Images.load()` before connect | Connect App Remote first |
|
|
560
|
-
| `INVALID_URI` | Item has no loadable image | Skip artwork or use placeholder |
|
|
561
|
-
| `IMAGE_LOAD_FAILED` | Spotify or disk write failed | Retry; check free space |
|
|
562
|
-
| `UNKNOWN` | Other image error | Read `e.message` |
|
|
563
|
-
|
|
564
|
-
## Platform differences (parity)
|
|
565
|
-
|
|
566
|
-
| Topic | iOS | Android |
|
|
567
|
-
| --- | --- | --- |
|
|
568
|
-
| `AppRemote.connect(accessToken)` | Token passed to `SPTAppRemote` | Token accepted for API parity; SDK uses session cached in Spotify app from prior auth |
|
|
569
|
-
| `Auth.cancelPending()` | Clears stuck `SPTSessionManager` state | No-op |
|
|
570
|
-
| Refresh token without swap | Possible (iOS TOKEN flow) | Not available — use `tokenSwapURL` |
|
|
571
|
-
| `session.scopes` without swap | Granted scopes returned | Requested scopes only (not granted list) |
|
|
572
|
-
| Premium / player metadata | Full App Remote when Premium | Free accounts often lack track titles / on-demand play |
|
|
573
|
-
| Content / Images | Requires recent Spotify app | Same |
|
|
574
|
-
|
|
575
|
-
## Android implicit (TOKEN) flow is not recommended
|
|
576
|
-
|
|
577
|
-
When `Auth.authenticate()` is called on Android **without** a `tokenSwapURL`, the Spotify Android SDK uses the implicit (TOKEN) flow. This flow has two hard limitations that **will not be fixed** — Spotify has deprecated it:
|
|
578
|
-
|
|
579
|
-
1. **No `refreshToken`.** The Android SDK does not expose a refresh token for implicit grants. `session.refreshToken` will always be `null`.
|
|
580
|
-
2. **`scopes` reflects what was requested, not what was granted.** The Android SDK does not return the actual granted scope list for TOKEN responses.
|
|
581
|
-
|
|
582
|
-
The library emits a one-time `console.warn` at runtime when this path is taken.
|
|
583
|
-
|
|
584
|
-
**The fix:** provide a `tokenSwapURL` to use the Authorization Code flow, which returns a full `refreshToken` and the actual granted `scopes` on both platforms.
|
|
585
|
-
|
|
586
|
-
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.
|
|
587
|
-
|
|
588
|
-
## Token swap server
|
|
589
|
-
|
|
590
|
-
The `tokenSwapURL` / `tokenRefreshURL` endpoints must be a server you control — **never** put your Spotify `CLIENT_SECRET` in the app bundle.
|
|
591
|
-
|
|
592
|
-
### Swap endpoint (`POST {tokenSwapURL}`)
|
|
593
|
-
|
|
594
|
-
The native module sends a `application/x-www-form-urlencoded` body:
|
|
595
|
-
|
|
596
|
-
```
|
|
597
|
-
code=<authorization-code>&redirect_uri=<redirect-uri>&client_id=<client-id>
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
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`:
|
|
601
|
-
|
|
602
|
-
```json
|
|
603
|
-
{
|
|
604
|
-
"access_token": "BQA...",
|
|
605
|
-
"token_type": "Bearer",
|
|
606
|
-
"expires_in": 3600,
|
|
607
|
-
"refresh_token": "AQA...",
|
|
608
|
-
"scope": "user-read-email streaming"
|
|
609
|
-
}
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
### Refresh endpoint (`POST {tokenRefreshURL}`)
|
|
613
|
-
|
|
614
|
-
The native module sends:
|
|
615
|
-
|
|
616
|
-
```
|
|
617
|
-
refresh_token=<token>&client_id=<client-id>
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
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.
|
|
621
|
-
|
|
622
|
-
### Error responses
|
|
623
|
-
|
|
624
|
-
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`.
|
|
625
|
-
|
|
626
|
-
### Reference implementation
|
|
627
|
-
|
|
628
|
-
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.
|
|
629
|
-
|
|
630
|
-
**Before running the example, you need a Spotify app:**
|
|
631
|
-
|
|
632
|
-
> **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.
|
|
633
|
-
|
|
634
|
-
1. Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and create an app (or use an existing one).
|
|
635
|
-
2. In the app settings, under **APIs used**, enable **Web API** (required for the `/v1/me` profile call).
|
|
636
|
-
3. Under **Redirect URIs**, add `expo-spotify-sdk-example://authenticate` exactly and save.
|
|
637
|
-
4. Under **User Management**, add the Spotify accounts that will test the app (up to 5 in Development Mode).
|
|
638
|
-
|
|
639
|
-
**Then populate the credentials:**
|
|
640
|
-
|
|
641
|
-
```sh
|
|
642
|
-
cd example
|
|
643
|
-
cp .env.local.example .env.local
|
|
644
|
-
```
|
|
645
|
-
|
|
646
|
-
Edit `.env.local` — all three values are required:
|
|
647
|
-
|
|
648
|
-
```sh
|
|
649
|
-
# From your Spotify app's dashboard page
|
|
650
|
-
SPOTIFY_CLIENT_ID=your_client_id_here
|
|
651
|
-
SPOTIFY_CLIENT_SECRET=your_client_secret_here
|
|
652
|
-
|
|
653
|
-
# Must match the redirect URI you registered in the Spotify Dashboard
|
|
654
|
-
SPOTIFY_REDIRECT_URI=expo-spotify-sdk-example://authenticate
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
`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**.
|
|
658
|
-
|
|
659
|
-
**Then run the app:**
|
|
660
|
-
|
|
661
|
-
```sh
|
|
662
|
-
npx expo start
|
|
663
|
-
```
|
|
664
|
-
|
|
665
|
-
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 |
|
|
666
383
|
|
|
667
384
|
## Troubleshooting
|
|
668
385
|
|
|
@@ -678,7 +395,7 @@ Ensure your app's URL scheme is registered in Xcode under **Info → URL Types**
|
|
|
678
395
|
**`AUTH_IN_PROGRESS`**
|
|
679
396
|
`Auth.authenticate()` was called while a previous call was still pending. Usually a concurrent call — wait for the first to settle.
|
|
680
397
|
|
|
681
|
-
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.
|
|
682
399
|
|
|
683
400
|
**App Remote: `CONNECTION_FAILED` / `Connection refused` (iOS code 61)**
|
|
684
401
|
The Spotify app is installed but its App Remote transport is not accepting connections. Common causes:
|
|
@@ -689,7 +406,7 @@ The Spotify app is installed but its App Remote transport is not accepting conne
|
|
|
689
406
|
4. **Retry loop on startup** — avoid calling `connect()` on every render while `connectionState === "disconnected"`; gate on a one-shot flag or user action.
|
|
690
407
|
|
|
691
408
|
**Token swap: `NETWORK_ERROR` / `Could not connect to the server` to `http://127.0.0.1:…/swap`**
|
|
692
|
-
The swap URL must be reachable from the device running your app. During local dev, the Expo dev server must be running and serving API routes. Omit `tokenSwapURL` to test auth without a swap server (iOS TOKEN flow only; see [
|
|
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)).
|
|
693
410
|
|
|
694
411
|
**Now playing shows no track title (Android, Free account)**
|
|
695
412
|
App Remote player state is limited for non-Premium users. Check `GET /v1/me` → `product` and `User.getCapabilities().canPlayOnDemand`.
|
|
@@ -697,7 +414,8 @@ App Remote player state is limited for non-Premium users. Check `GET /v1/me` →
|
|
|
697
414
|
## Related docs
|
|
698
415
|
|
|
699
416
|
- [CONTEXT.md](./CONTEXT.md) — terminology (Auth SDK vs App Remote vs Web API)
|
|
700
|
-
- [docs/
|
|
417
|
+
- [docs/api-reference.md](./docs/api-reference.md) — method reference
|
|
418
|
+
- [docs/error-codes.md](./docs/error-codes.md) — error code tables
|
|
701
419
|
- [docs/QA_CHECKLIST.md](./docs/QA_CHECKLIST.md) — manual QA before a `2.x` release on `main` (or `1.x` on `v1`)
|
|
702
420
|
- [docs/RELEASE.md](./docs/RELEASE.md) — Release Please on `main` (`2.x`); maintenance releases from `v1` (`1.x`)
|
|
703
421
|
- [ATTRIBUTION.md](./ATTRIBUTION.md) — third-party SDKs and scope boundaries
|
|
@@ -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 = "2.
|
|
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,
|
|
@@ -18,7 +18,6 @@ import com.spotify.protocol.types.ListItems
|
|
|
18
18
|
import com.spotify.protocol.types.PlayerState
|
|
19
19
|
import com.spotify.protocol.types.PlaybackSpeed
|
|
20
20
|
import com.spotify.protocol.types.Repeat
|
|
21
|
-
import expo.modules.kotlin.exception.CodedException
|
|
22
21
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
23
22
|
import kotlinx.coroutines.sync.Mutex
|
|
24
23
|
import java.io.File
|
|
@@ -336,7 +335,7 @@ class SpotifyAppRemoteCoordinator {
|
|
|
336
335
|
suspendCancellableCoroutine { continuation ->
|
|
337
336
|
setResultCallback { continuation.resume(Unit) }
|
|
338
337
|
setErrorCallback { throwable ->
|
|
339
|
-
continuation.resumeWithException(
|
|
338
|
+
continuation.resumeWithException(SpotifyAppRemoteErrorMapping.mapPlayerError(throwable, callsite))
|
|
340
339
|
}
|
|
341
340
|
}
|
|
342
341
|
}
|
|
@@ -347,7 +346,7 @@ class SpotifyAppRemoteCoordinator {
|
|
|
347
346
|
return suspendCancellableCoroutine { continuation ->
|
|
348
347
|
setResultCallback { result -> continuation.resume(result) }
|
|
349
348
|
setErrorCallback { throwable ->
|
|
350
|
-
continuation.resumeWithException(
|
|
349
|
+
continuation.resumeWithException(SpotifyAppRemoteErrorMapping.mapPlayerError(throwable, callsite))
|
|
351
350
|
}
|
|
352
351
|
}
|
|
353
352
|
}
|
|
@@ -357,7 +356,7 @@ class SpotifyAppRemoteCoordinator {
|
|
|
357
356
|
return suspendCancellableCoroutine { continuation ->
|
|
358
357
|
setResultCallback { result -> continuation.resume(result) }
|
|
359
358
|
setErrorCallback { throwable ->
|
|
360
|
-
continuation.resumeWithException(
|
|
359
|
+
continuation.resumeWithException(SpotifyAppRemoteErrorMapping.mapUserError(throwable, callsite, uri))
|
|
361
360
|
}
|
|
362
361
|
}
|
|
363
362
|
}
|
|
@@ -366,7 +365,7 @@ class SpotifyAppRemoteCoordinator {
|
|
|
366
365
|
return suspendCancellableCoroutine { continuation ->
|
|
367
366
|
setResultCallback { result -> continuation.resume(result) }
|
|
368
367
|
setErrorCallback { throwable ->
|
|
369
|
-
continuation.resumeWithException(
|
|
368
|
+
continuation.resumeWithException(SpotifyAppRemoteErrorMapping.mapUserError(throwable, callsite, ""))
|
|
370
369
|
}
|
|
371
370
|
}
|
|
372
371
|
}
|
|
@@ -375,7 +374,7 @@ class SpotifyAppRemoteCoordinator {
|
|
|
375
374
|
return suspendCancellableCoroutine { continuation ->
|
|
376
375
|
setResultCallback { result -> continuation.resume(result) }
|
|
377
376
|
setErrorCallback { throwable ->
|
|
378
|
-
continuation.resumeWithException(
|
|
377
|
+
continuation.resumeWithException(SpotifyAppRemoteErrorMapping.mapContentError(throwable, callsite))
|
|
379
378
|
}
|
|
380
379
|
}
|
|
381
380
|
}
|
|
@@ -384,68 +383,11 @@ class SpotifyAppRemoteCoordinator {
|
|
|
384
383
|
return suspendCancellableCoroutine { continuation ->
|
|
385
384
|
setResultCallback { result -> continuation.resume(result) }
|
|
386
385
|
setErrorCallback { throwable ->
|
|
387
|
-
continuation.resumeWithException(
|
|
386
|
+
continuation.resumeWithException(SpotifyAppRemoteErrorMapping.mapImagesError(throwable, callsite))
|
|
388
387
|
}
|
|
389
388
|
}
|
|
390
389
|
}
|
|
391
390
|
|
|
392
|
-
private fun normalizePlayerError(throwable: Throwable, callsite: String): CodedException {
|
|
393
|
-
val msg = throwable.message ?: "Unknown error"
|
|
394
|
-
return when {
|
|
395
|
-
msg.contains("premium", ignoreCase = true) -> PlayerPremiumRequiredException(callsite)
|
|
396
|
-
msg.contains("disconnected", ignoreCase = true) ||
|
|
397
|
-
msg.contains("not connected", ignoreCase = true) ->
|
|
398
|
-
PlayerConnectionLostException("$callsite: $msg", throwable)
|
|
399
|
-
msg.contains("not allowed", ignoreCase = true) ||
|
|
400
|
-
msg.contains("restriction", ignoreCase = true) ->
|
|
401
|
-
PlayerOperationNotAllowedException("$callsite: $msg", throwable)
|
|
402
|
-
else -> PlayerUnknownException("$callsite: $msg", throwable)
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
private fun normalizeUserError(throwable: Throwable, callsite: String, uri: String): CodedException {
|
|
407
|
-
val msg = throwable.message ?: "Unknown error"
|
|
408
|
-
return when {
|
|
409
|
-
msg.contains("disconnected", ignoreCase = true) ||
|
|
410
|
-
msg.contains("not connected", ignoreCase = true) ->
|
|
411
|
-
UserConnectionLostException("$callsite: $msg", throwable)
|
|
412
|
-
msg.contains("uri", ignoreCase = true) ||
|
|
413
|
-
msg.contains("invalid", ignoreCase = true) ->
|
|
414
|
-
UserInvalidURIException(uri, throwable)
|
|
415
|
-
msg.contains("not allowed", ignoreCase = true) ||
|
|
416
|
-
msg.contains("restriction", ignoreCase = true) ->
|
|
417
|
-
UserOperationNotAllowedException("$callsite: $msg", throwable)
|
|
418
|
-
else -> UserUnknownException("$callsite: $msg", throwable)
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
private fun normalizeContentError(throwable: Throwable, callsite: String): CodedException {
|
|
423
|
-
val msg = throwable.message ?: "Unknown error"
|
|
424
|
-
return when {
|
|
425
|
-
msg.contains("disconnected", ignoreCase = true) ||
|
|
426
|
-
msg.contains("not connected", ignoreCase = true) ->
|
|
427
|
-
ContentConnectionLostException("$callsite: $msg", throwable)
|
|
428
|
-
msg.contains("not supported", ignoreCase = true) ||
|
|
429
|
-
msg.contains("unsupported", ignoreCase = true) ->
|
|
430
|
-
ContentApiUnavailableException("$callsite: content API unavailable on this Spotify app version", throwable)
|
|
431
|
-
else -> ContentUnknownException("$callsite: $msg", throwable)
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
private fun normalizeImagesError(throwable: Throwable, callsite: String): CodedException {
|
|
436
|
-
val msg = throwable.message ?: "Unknown error"
|
|
437
|
-
return when {
|
|
438
|
-
msg.contains("disconnected", ignoreCase = true) ||
|
|
439
|
-
msg.contains("not connected", ignoreCase = true) ->
|
|
440
|
-
ImagesNotConnectedException(callsite)
|
|
441
|
-
msg.contains("invalid", ignoreCase = true) ->
|
|
442
|
-
ImagesInvalidURIException("$callsite: invalid image identifier", throwable)
|
|
443
|
-
msg.contains("load", ignoreCase = true) || msg.contains("image", ignoreCase = true) ->
|
|
444
|
-
ImagesLoadFailedException("$callsite: $msg", throwable)
|
|
445
|
-
else -> ImagesUnknownException("$callsite: $msg", throwable)
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
391
|
private fun writeBitmapToTempFile(bitmap: Bitmap, cacheDir: File): File {
|
|
450
392
|
val out = File(cacheDir, "expo-spotify-image-${System.currentTimeMillis()}-${(0..9999).random()}.png")
|
|
451
393
|
FileOutputStream(out).use { stream ->
|