@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.
Files changed (49) hide show
  1. package/README.md +62 -344
  2. package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +3 -5
  3. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +6 -64
  4. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteErrorMapping.kt +106 -0
  5. package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +2 -7
  6. package/build/app-remote/index.d.ts.map +1 -1
  7. package/build/app-remote/index.js +11 -24
  8. package/build/app-remote/index.js.map +1 -1
  9. package/build/auth/index.d.ts.map +1 -1
  10. package/build/auth/index.js +17 -34
  11. package/build/auth/index.js.map +1 -1
  12. package/build/content/index.d.ts.map +1 -1
  13. package/build/content/index.js +12 -25
  14. package/build/content/index.js.map +1 -1
  15. package/build/hooks/index.d.ts +1 -1
  16. package/build/hooks/index.d.ts.map +1 -1
  17. package/build/hooks/index.js +38 -115
  18. package/build/hooks/index.js.map +1 -1
  19. package/build/images/index.d.ts +2 -2
  20. package/build/images/index.d.ts.map +1 -1
  21. package/build/images/index.js +12 -25
  22. package/build/images/index.js.map +1 -1
  23. package/build/index.d.ts +0 -18
  24. package/build/index.d.ts.map +1 -1
  25. package/build/index.js +0 -27
  26. package/build/index.js.map +1 -1
  27. package/build/internal/native-errors.d.ts +14 -0
  28. package/build/internal/native-errors.d.ts.map +1 -0
  29. package/build/internal/native-errors.js +29 -0
  30. package/build/internal/native-errors.js.map +1 -0
  31. package/build/internal/sync-external-store.d.ts +14 -0
  32. package/build/internal/sync-external-store.d.ts.map +1 -0
  33. package/build/internal/sync-external-store.js +36 -0
  34. package/build/internal/sync-external-store.js.map +1 -0
  35. package/build/player/index.d.ts.map +1 -1
  36. package/build/player/index.js +14 -27
  37. package/build/player/index.js.map +1 -1
  38. package/build/user/index.d.ts +2 -2
  39. package/build/user/index.d.ts.map +1 -1
  40. package/build/user/index.js +16 -26
  41. package/build/user/index.js.map +1 -1
  42. package/ios/ExpoSpotifySDKModule.swift +1 -1
  43. package/ios/SpotifyAppRemoteCoordinator.swift +29 -404
  44. package/ios/SpotifyAppRemoteErrorMapping.swift +110 -0
  45. package/ios/SpotifyAppRemoteErrors.swift +221 -0
  46. package/ios/SpotifyAppRemoteMappers.swift +88 -0
  47. package/package.json +4 -2
  48. package/plugin/index.d.ts +2 -0
  49. 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 | 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
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 (`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
 
@@ -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 { AppRemote, useConnectionState, useSession } from "@wwdrew/expo-spotify-sdk";
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 | Auth (`Auth.*`) | App Remote (`AppRemote.*`, `Player.*`, …) |
321
- | --- | --- | --- |
322
- | Spotify app installed | Recommended (native UX) | **Required** |
323
- | Spotify app running | No | **Required** — connect talks to the running Spotify process over IPC |
324
- | Spotify Premium | No | **Required** for reliable on-demand playback and rich player state (track name, transport, browse) |
325
- | 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` |
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
- v0.x top-level functions remain exported but are deprecated (scheduled for removal in a future major).
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 | v1 |
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
- `SpotifySession`, `SpotifyScope`, and config shapes are unchanged. For auth-specific `e.code` narrowing, use `instanceof AuthError` instead of `instanceof SpotifyError`.
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
- Starts OAuth via the installed Spotify app (or web fallback). Throws [`AuthError`](#autherror) on failure.
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
- Connects to the **running** Spotify app. All `Player`, `User`, `Content`, and `Images` calls require an active connection.
374
+ ## Documentation
435
375
 
436
- | Method | Description |
376
+ | Guide | Contents |
437
377
  | --- | --- |
438
- | `connect(accessToken)` | Open IPC to Spotify. Resolves when connected. |
439
- | `disconnect()` | Tear down connection. |
440
- | `isConnected()` | Synchronous snapshot. |
441
- | `getConnectionState()` | `"disconnected"` \| `"connecting"` \| `"connected"`. |
442
- | `addListener("connectionStateChange", cb)` | State transitions. |
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 [`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.
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 [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)).
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/V1_PLAN.md](./docs/V1_PLAN.md) — implementation plan and release criteria
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.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,
@@ -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(normalizePlayerError(throwable, callsite))
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(normalizePlayerError(throwable, callsite))
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(normalizeUserError(throwable, callsite, uri))
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(normalizeUserError(throwable, callsite, ""))
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(normalizeContentError(throwable, callsite))
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(normalizeImagesError(throwable, callsite))
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 ->