@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.
Files changed (61) hide show
  1. package/README.md +92 -350
  2. package/android/build.gradle +2 -2
  3. package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +3 -5
  4. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +6 -64
  5. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteErrorMapping.kt +106 -0
  6. package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +2 -7
  7. package/app.plugin.js +1 -1
  8. package/build/app-remote/index.d.ts +2 -2
  9. package/build/app-remote/index.d.ts.map +1 -1
  10. package/build/app-remote/index.js +11 -24
  11. package/build/app-remote/index.js.map +1 -1
  12. package/build/auth/index.d.ts +2 -2
  13. package/build/auth/index.d.ts.map +1 -1
  14. package/build/auth/index.js +17 -34
  15. package/build/auth/index.js.map +1 -1
  16. package/build/content/index.d.ts.map +1 -1
  17. package/build/content/index.js +12 -25
  18. package/build/content/index.js.map +1 -1
  19. package/build/hooks/index.d.ts +4 -4
  20. package/build/hooks/index.d.ts.map +1 -1
  21. package/build/hooks/index.js +38 -115
  22. package/build/hooks/index.js.map +1 -1
  23. package/build/images/index.d.ts +2 -2
  24. package/build/images/index.d.ts.map +1 -1
  25. package/build/images/index.js +12 -25
  26. package/build/images/index.js.map +1 -1
  27. package/build/index.d.ts +0 -18
  28. package/build/index.d.ts.map +1 -1
  29. package/build/index.js +0 -27
  30. package/build/index.js.map +1 -1
  31. package/build/internal/native-errors.d.ts +14 -0
  32. package/build/internal/native-errors.d.ts.map +1 -0
  33. package/build/internal/native-errors.js +29 -0
  34. package/build/internal/native-errors.js.map +1 -0
  35. package/build/internal/sync-external-store.d.ts +14 -0
  36. package/build/internal/sync-external-store.d.ts.map +1 -0
  37. package/build/internal/sync-external-store.js +36 -0
  38. package/build/internal/sync-external-store.js.map +1 -0
  39. package/build/player/index.d.ts +1 -1
  40. package/build/player/index.d.ts.map +1 -1
  41. package/build/player/index.js +15 -27
  42. package/build/player/index.js.map +1 -1
  43. package/build/uri/index.js.map +1 -1
  44. package/build/user/index.d.ts +2 -2
  45. package/build/user/index.d.ts.map +1 -1
  46. package/build/user/index.js +16 -26
  47. package/build/user/index.js.map +1 -1
  48. package/ios/ExpoSpotifySDK.podspec +1 -1
  49. package/ios/ExpoSpotifySDKModule.swift +1 -1
  50. package/ios/SpotifyAppRemoteCoordinator.swift +29 -404
  51. package/ios/SpotifyAppRemoteErrorMapping.swift +110 -0
  52. package/ios/SpotifyAppRemoteErrors.swift +221 -0
  53. package/ios/SpotifyAppRemoteMappers.swift +88 -0
  54. package/package.json +28 -14
  55. package/plugin/build/index.d.ts +20 -3
  56. package/plugin/build/index.js +22 -25
  57. package/plugin/build/types.d.ts +17 -1
  58. package/plugin/build/withSpotifySdkConfig.d.ts +4 -0
  59. package/plugin/build/withSpotifySdkConfig.js +27 -0
  60. package/plugin/index.d.ts +2 -0
  61. 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 | iOS | Android |
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`** | 55 | 15.1 | `v1` (long-lived maintenance) |
31
- | **`2.x`** | 56+ | 16.4 | `main` |
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 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`).
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
- ## v1 namespaced API
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 (`authenticateAsync`, `isAvailable`, etc.) are still exported for backward compatibility but are deprecated — see [Migration from v0.x](#migration-from-v0x).
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 { AppRemote, useConnectionState, useSession } from "@wwdrew/expo-spotify-sdk";
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 | 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` |
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
- v0.x top-level functions remain exported but are deprecated (removed in v2.0.0).
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 | v1 |
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
- `SpotifySession`, `SpotifyScope`, and config shapes are unchanged. For auth-specific `e.code` narrowing, use `instanceof AuthError` instead of `instanceof SpotifyError`.
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
- 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.
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
- Connects to the **running** Spotify app. All `Player`, `User`, `Content`, and `Images` calls require an active connection.
374
+ ## Documentation
411
375
 
412
- | Method | Description |
376
+ | Guide | Contents |
413
377
  | --- | --- |
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
-
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 [`Auth.cancelPending()`](#authcancelpending-promisevoid) before retrying.
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 [Android implicit flow](#android-implicit-token-flow-is-not-recommended)).
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/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
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
@@ -16,10 +16,10 @@ buildscript {
16
16
  }
17
17
 
18
18
  project.android {
19
- compileSdkVersion safeExtGet("compileSdkVersion", 35)
19
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
20
20
  defaultConfig {
21
21
  minSdkVersion safeExtGet("minSdkVersion", 24)
22
- targetSdkVersion safeExtGet("targetSdkVersion", 35)
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.0" // x-release-please-version
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, manifest.clientId)
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 manifest = readManifestConfig()
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,