@wwdrew/expo-spotify-sdk 2.2.0 → 2.2.2

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 CHANGED
@@ -103,7 +103,7 @@ For bare React Native (no Expo CLI), see [Installation in bare React Native](#in
103
103
 
104
104
  | Platform | Distribution |
105
105
  | --- | --- |
106
- | **iOS** | `SpotifyiOS` resolved from Spotify's GitHub via SPM at `pod install` ([ADR-0008](./docs/adr/0008-ios-spotify-sdk-via-spm.md)). Uses React Native's `spm_dependency` (RN 0.75+). Supported Expo SDK lanes: [Platform support](#platform-support). |
106
+ | **iOS** | `SpotifyiOS` downloaded via CocoaPods HTTP binary pod at `pod install` ([ADR-0009](./docs/adr/0009-ios-spotify-sdk-via-cocoapods-binary-pod.md)). Config plugin injects the Podfile `pod` line on `expo prebuild`. Supported Expo SDK lanes: [Platform support](#platform-support). |
107
107
  | **Android** | App Remote AAR downloaded from Spotify's GitHub at Gradle build. Auth SDK from Maven Central. |
108
108
 
109
109
  | You are… | Action |
@@ -127,10 +127,11 @@ yarn add @wwdrew/expo-spotify-sdk expo-modules-core
127
127
 
128
128
  ### 2. iOS
129
129
 
130
- Add the pod to your `Podfile`:
130
+ Add pods to your `Podfile`:
131
131
 
132
132
  ```ruby
133
133
  pod 'ExpoSpotifySDK', :path => '../node_modules/@wwdrew/expo-spotify-sdk'
134
+ pod 'SpotifyiOS', :podspec => File.join(__dir__, '../node_modules/@wwdrew/expo-spotify-sdk/spotify-ios/SpotifyiOS.podspec')
134
135
  ```
135
136
 
136
137
  Then install pods:
@@ -71,9 +71,7 @@ tasks.register("downloadSpotifyAppRemoteAar") {
71
71
  ant.get(
72
72
  src: downloadUrl,
73
73
  dest: tmp,
74
- verbose: true,
75
- connecttimeout: 30000,
76
- readtimeout: 60000
74
+ verbose: true
77
75
  )
78
76
  def actual = sha256File(tmp)
79
77
  if (actual != appRemoteSha256) {
@@ -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.2.0" // x-release-please-version
11
+ private const val SDK_VERSION = "2.2.2" // 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"
@@ -49,37 +49,12 @@ export declare const AppRemote: {
49
49
  */
50
50
  readonly connect: (accessToken: string) => Promise<void>;
51
51
  /**
52
- * Wakes the Spotify app — launching it if it has been suspended in the
53
- * background starts playback, and then establishes the App Remote
54
- * connection. This is the recommended way to (re)connect when Spotify may
55
- * not currently be running.
52
+ * Wakes Spotify (launching if suspended), starts or resumes playback, and
53
+ * connects. Use when {@link connect} may fail because Spotify is not running.
54
+ * Always foregrounds Spotify and starts audio see package api-reference.
56
55
  *
57
- * Why this exists: {@link connect} only attaches to an *already-running*
58
- * Spotify process. If Spotify has been suspended (e.g. iOS reclaims a
59
- * backgrounded app that isn't playing audio, or the connection dropped
60
- * ~30s after the user paused), `connect()` rejects with `CONNECTION_FAILED`
61
- * and there is no way to revive Spotify from `connect()` alone.
62
- * `authorizeAndPlay()` performs an app-switch to Spotify
63
- * (iOS `authorizeAndPlayURI`; Android wakes the App Remote service and
64
- * issues a play command), which both revives Spotify and keeps it alive by
65
- * starting audio.
66
- *
67
- * Trade-offs:
68
- * - It **always starts (or resumes) playback** — it cannot wake Spotify
69
- * silently.
70
- * - It briefly **switches to the Spotify app** and back; the OS does not
71
- * allow waking another app without a foreground app-switch.
72
- * - Requires Spotify Premium for on-demand playback of a specific `uri`.
73
- *
74
- * @param accessToken Access token from `Auth.authenticate()`. On Android this
75
- * is an iOS-parity parameter (see {@link connect}); on iOS the token
76
- * returned by the authorization redirect is used to complete the connection.
77
- * @param uri Spotify URI to play. Pass an empty string (the default) to
78
- * resume the user's last track or play a contextual/random track.
79
- *
80
- * Resolves once Spotify is connected and the play command has been issued;
81
- * rejects with an {@link AppRemoteError} if Spotify is not installed or the
82
- * authorization is denied.
56
+ * @param accessToken Access token from `Auth.authenticate()`.
57
+ * @param uri Spotify URI to play, or empty (default) to resume last/contextual track.
83
58
  */
84
59
  readonly authorizeAndPlay: (accessToken: string, uri?: string) => Promise<void>;
85
60
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/app-remote/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAG3D,OAAO,EAAkB,KAAK,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAGlE,YAAY,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAMzC,8DAA8D;AAC9D,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,CAAC;AAE1E,oDAAoD;AACpD,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,eAAe,CAAC;CACxB;AAED,8CAA8C;AAC9C,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;CACjB;AAqBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS;IACpB;;;;;;;;;;;OAWG;oCACkB,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;IAM3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;6CAC2B,MAAM,QAAO,MAAM,KAAQ,OAAO,CAAC,IAAI,CAAC;IAOtE;;;OAGG;+BACW,OAAO,CAAC,IAAI,CAAC;IAI3B;;;;OAIG;gCACY,OAAO;IAItB;;;OAGG;uCACmB,OAAO,CAAC,eAAe,CAAC;IAI9C;;;;;;;;;OASG;2BACS,CAAC,SAAS,uBAAuB,GAAG,iBAAiB,SACxD,CAAC,YACE,CAAC,SAAS,uBAAuB,GACvC,CAAC,KAAK,EAAE,0BAA0B,KAAK,IAAI,GAC3C,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,KACxC,iBAAiB;CAUZ,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/app-remote/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAG3D,OAAO,EAAkB,KAAK,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAGlE,YAAY,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAMzC,8DAA8D;AAC9D,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,CAAC;AAE1E,oDAAoD;AACpD,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,eAAe,CAAC;CACxB;AAED,8CAA8C;AAC9C,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;CACjB;AAqBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS;IACpB;;;;;;;;;;;OAWG;oCACkB,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;IAM3C;;;;;;;OAOG;6CAC2B,MAAM,QAAO,MAAM,KAAQ,OAAO,CAAC,IAAI,CAAC;IAOtE;;;OAGG;+BACW,OAAO,CAAC,IAAI,CAAC;IAI3B;;;;OAIG;gCACY,OAAO;IAItB;;;OAGG;uCACmB,OAAO,CAAC,eAAe,CAAC;IAI9C;;;;;;;;;OASG;2BACS,CAAC,SAAS,uBAAuB,GAAG,iBAAiB,SACxD,CAAC,YACE,CAAC,SAAS,uBAAuB,GACvC,CAAC,KAAK,EAAE,0BAA0B,KAAK,IAAI,GAC3C,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,KACxC,iBAAiB;CAUZ,CAAC"}
@@ -56,37 +56,12 @@ export const AppRemote = {
56
56
  return ExpoSpotifySDKModule.appRemoteConnect(accessToken).catch(rethrowAsAppRemoteError);
57
57
  },
58
58
  /**
59
- * Wakes the Spotify app — launching it if it has been suspended in the
60
- * background starts playback, and then establishes the App Remote
61
- * connection. This is the recommended way to (re)connect when Spotify may
62
- * not currently be running.
59
+ * Wakes Spotify (launching if suspended), starts or resumes playback, and
60
+ * connects. Use when {@link connect} may fail because Spotify is not running.
61
+ * Always foregrounds Spotify and starts audio see package api-reference.
63
62
  *
64
- * Why this exists: {@link connect} only attaches to an *already-running*
65
- * Spotify process. If Spotify has been suspended (e.g. iOS reclaims a
66
- * backgrounded app that isn't playing audio, or the connection dropped
67
- * ~30s after the user paused), `connect()` rejects with `CONNECTION_FAILED`
68
- * and there is no way to revive Spotify from `connect()` alone.
69
- * `authorizeAndPlay()` performs an app-switch to Spotify
70
- * (iOS `authorizeAndPlayURI`; Android wakes the App Remote service and
71
- * issues a play command), which both revives Spotify and keeps it alive by
72
- * starting audio.
73
- *
74
- * Trade-offs:
75
- * - It **always starts (or resumes) playback** — it cannot wake Spotify
76
- * silently.
77
- * - It briefly **switches to the Spotify app** and back; the OS does not
78
- * allow waking another app without a foreground app-switch.
79
- * - Requires Spotify Premium for on-demand playback of a specific `uri`.
80
- *
81
- * @param accessToken Access token from `Auth.authenticate()`. On Android this
82
- * is an iOS-parity parameter (see {@link connect}); on iOS the token
83
- * returned by the authorization redirect is used to complete the connection.
84
- * @param uri Spotify URI to play. Pass an empty string (the default) to
85
- * resume the user's last track or play a contextual/random track.
86
- *
87
- * Resolves once Spotify is connected and the play command has been issued;
88
- * rejects with an {@link AppRemoteError} if Spotify is not installed or the
89
- * authorization is denied.
63
+ * @param accessToken Access token from `Auth.authenticate()`.
64
+ * @param uri Spotify URI to play, or empty (default) to resume last/contextual track.
90
65
  */
91
66
  authorizeAndPlay(accessToken, uri = "") {
92
67
  return ExpoSpotifySDKModule.appRemoteAuthorizeAndPlay(accessToken, uri).catch(rethrowAsAppRemoteError);
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/app-remote/index.ts"],"names":[],"mappings":"AAEA,OAAO,oBAAoB,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAA2B,MAAM,SAAS,CAAC;AAClE,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAGrE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAoBzC,8EAA8E;AAC9E,mBAAmB;AACnB,8EAA8E;AAE9E,MAAM,uBAAuB,GAAG,wBAAwB,CAAC;IACvD,UAAU,EAAE,cAAc;IAC1B,WAAW,EAAE,SAAS;IACtB,UAAU,EAAE,IAAI,GAAG,CAAqB;QACtC,mBAAmB;QACnB,iBAAiB;QACjB,eAAe;QACf,SAAS;KACV,CAAC;CACH,CAAC,CAAC;AAEH,8EAA8E;AAC9E,sBAAsB;AACtB,8EAA8E;AAE9E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,WAAmB;QACzB,OAAO,oBAAoB,CAAC,gBAAgB,CAAC,WAAW,CAAC,CAAC,KAAK,CAC7D,uBAAuB,CACxB,CAAC;IACJ,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,gBAAgB,CAAC,WAAmB,EAAE,MAAc,EAAE;QACpD,OAAO,oBAAoB,CAAC,yBAAyB,CACnD,WAAW,EACX,GAAG,CACJ,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;IACnC,CAAC;IAED;;;OAGG;IACH,UAAU;QACR,OAAO,oBAAoB,CAAC,mBAAmB,EAAE,CAAC;IACpD,CAAC;IAED;;;;OAIG;IACH,WAAW;QACT,OAAO,oBAAoB,CAAC,oBAAoB,EAAE,CAAC;IACrD,CAAC;IAED;;;OAGG;IACH,kBAAkB;QAChB,OAAO,oBAAoB,CAAC,2BAA2B,EAAE,CAAC;IAC5D,CAAC;IAED;;;;;;;;;OASG;IACH,WAAW,CACT,KAAQ,EACR,QAEyC;QAEzC,MAAM,WAAW,GACf,KAAK,KAAK,uBAAuB;YAC/B,CAAC,CAAC,yBAAyB;YAC3B,CAAC,CAAC,mBAAmB,CAAC;QAC1B,OAAO,oBAAoB,CAAC,WAAW,CACrC,WAAW,EACX,QAAoC,CAChB,CAAC;IACzB,CAAC;CACO,CAAC","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\n\nimport ExpoSpotifySDKModule from \"../ExpoSpotifySDKModule\";\nimport { AppRemoteError, type AppRemoteErrorCode } from \"./error\";\nimport { createNativeErrorRethrow } from \"../internal/native-errors\";\n\nexport type { AppRemoteErrorCode } from \"./error\";\nexport { AppRemoteError } from \"./error\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Current state of the IPC connection to the Spotify app. */\nexport type ConnectionState = \"disconnected\" | \"connecting\" | \"connected\";\n\n/** Payload of the `connectionStateChange` event. */\nexport interface ConnectionStateChangeEvent {\n state: ConnectionState;\n}\n\n/** Payload of the `connectionError` event. */\nexport interface ConnectionErrorEvent {\n code: AppRemoteErrorCode;\n message: string;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst rethrowAsAppRemoteError = createNativeErrorRethrow({\n ErrorClass: AppRemoteError,\n unknownCode: \"UNKNOWN\",\n validCodes: new Set<AppRemoteErrorCode>([\n \"CONNECTION_FAILED\",\n \"CONNECTION_LOST\",\n \"NOT_CONNECTED\",\n \"UNKNOWN\",\n ]),\n});\n\n// ---------------------------------------------------------------------------\n// AppRemote namespace\n// ---------------------------------------------------------------------------\n\n/**\n * Spotify App Remote namespace. Manages the IPC connection to the running\n * Spotify app. All `Player`, `User`, `Content`, and `Images` calls require an\n * active connection established via `AppRemote.connect()`.\n *\n * @example\n * ```ts\n * import { AppRemote } from \"@wwdrew/expo-spotify-sdk\";\n *\n * // Connect using the access token from Auth.authenticate()\n * await AppRemote.connect(session.accessToken);\n *\n * const sub = AppRemote.addListener(\"connectionStateChange\", ({ state }) => {\n * console.log(\"connection state:\", state);\n * });\n *\n * // later:\n * await AppRemote.disconnect();\n * sub.remove();\n * ```\n */\nexport const AppRemote = {\n /**\n * Opens a connection to the running Spotify app using the provided access\n * token. Resolves when the connection is established; rejects with an\n * {@link AppRemoteError} if the connection fails.\n *\n * **Android note:** The access token is accepted for API parity with iOS but\n * the Android App Remote SDK does not accept it directly — it uses the\n * session cached in the Spotify app from your earlier `Auth.authenticate()`\n * call. Ensure `Auth.authenticate()` has succeeded before calling `connect()`.\n *\n * Calling `connect()` while already connected is a no-op.\n */\n connect(accessToken: string): Promise<void> {\n return ExpoSpotifySDKModule.appRemoteConnect(accessToken).catch(\n rethrowAsAppRemoteError,\n );\n },\n\n /**\n * Wakes the Spotify app — launching it if it has been suspended in the\n * background — starts playback, and then establishes the App Remote\n * connection. This is the recommended way to (re)connect when Spotify may\n * not currently be running.\n *\n * Why this exists: {@link connect} only attaches to an *already-running*\n * Spotify process. If Spotify has been suspended (e.g. iOS reclaims a\n * backgrounded app that isn't playing audio, or the connection dropped\n * ~30s after the user paused), `connect()` rejects with `CONNECTION_FAILED`\n * and there is no way to revive Spotify from `connect()` alone.\n * `authorizeAndPlay()` performs an app-switch to Spotify\n * (iOS `authorizeAndPlayURI`; Android wakes the App Remote service and\n * issues a play command), which both revives Spotify and keeps it alive by\n * starting audio.\n *\n * Trade-offs:\n * - It **always starts (or resumes) playback** it cannot wake Spotify\n * silently.\n * - It briefly **switches to the Spotify app** and back; the OS does not\n * allow waking another app without a foreground app-switch.\n * - Requires Spotify Premium for on-demand playback of a specific `uri`.\n *\n * @param accessToken Access token from `Auth.authenticate()`. On Android this\n * is an iOS-parity parameter (see {@link connect}); on iOS the token\n * returned by the authorization redirect is used to complete the connection.\n * @param uri Spotify URI to play. Pass an empty string (the default) to\n * resume the user's last track or play a contextual/random track.\n *\n * Resolves once Spotify is connected and the play command has been issued;\n * rejects with an {@link AppRemoteError} if Spotify is not installed or the\n * authorization is denied.\n */\n authorizeAndPlay(accessToken: string, uri: string = \"\"): Promise<void> {\n return ExpoSpotifySDKModule.appRemoteAuthorizeAndPlay(\n accessToken,\n uri,\n ).catch(rethrowAsAppRemoteError);\n },\n\n /**\n * Disconnects from the Spotify app. Safe to call when already disconnected.\n * Resolves once the disconnection is complete.\n */\n disconnect(): Promise<void> {\n return ExpoSpotifySDKModule.appRemoteDisconnect();\n },\n\n /**\n * Returns `true` if currently connected to the Spotify app.\n * This is a synchronous snapshot — subscribe to `\"connectionStateChange\"`\n * for reactive updates.\n */\n isConnected(): boolean {\n return ExpoSpotifySDKModule.appRemoteIsConnected();\n },\n\n /**\n * Returns the current {@link ConnectionState} synchronously.\n * Subscribe to `\"connectionStateChange\"` for reactive updates.\n */\n getConnectionState(): Promise<ConnectionState> {\n return ExpoSpotifySDKModule.appRemoteGetConnectionState();\n },\n\n /**\n * Subscribes to connection lifecycle events.\n *\n * | Event | Payload | When |\n * |---|---|---|\n * | `\"connectionStateChange\"` | `{ state: ConnectionState }` | State transitions |\n * | `\"connectionError\"` | `{ code, message }` | Connection failures and drops |\n *\n * Returns a `Subscription` — call `.remove()` to unsubscribe.\n */\n addListener<E extends \"connectionStateChange\" | \"connectionError\">(\n event: E,\n listener: E extends \"connectionStateChange\"\n ? (event: ConnectionStateChangeEvent) => void\n : (event: ConnectionErrorEvent) => void,\n ): EventSubscription {\n const nativeEvent =\n event === \"connectionStateChange\"\n ? \"onConnectionStateChange\"\n : \"onConnectionError\";\n return ExpoSpotifySDKModule.addListener(\n nativeEvent,\n listener as (event: unknown) => void,\n ) as EventSubscription;\n },\n} as const;\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/app-remote/index.ts"],"names":[],"mappings":"AAEA,OAAO,oBAAoB,MAAM,yBAAyB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAA2B,MAAM,SAAS,CAAC;AAClE,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAGrE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAoBzC,8EAA8E;AAC9E,mBAAmB;AACnB,8EAA8E;AAE9E,MAAM,uBAAuB,GAAG,wBAAwB,CAAC;IACvD,UAAU,EAAE,cAAc;IAC1B,WAAW,EAAE,SAAS;IACtB,UAAU,EAAE,IAAI,GAAG,CAAqB;QACtC,mBAAmB;QACnB,iBAAiB;QACjB,eAAe;QACf,SAAS;KACV,CAAC;CACH,CAAC,CAAC;AAEH,8EAA8E;AAC9E,sBAAsB;AACtB,8EAA8E;AAE9E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,WAAmB;QACzB,OAAO,oBAAoB,CAAC,gBAAgB,CAAC,WAAW,CAAC,CAAC,KAAK,CAC7D,uBAAuB,CACxB,CAAC;IACJ,CAAC;IAED;;;;;;;OAOG;IACH,gBAAgB,CAAC,WAAmB,EAAE,MAAc,EAAE;QACpD,OAAO,oBAAoB,CAAC,yBAAyB,CACnD,WAAW,EACX,GAAG,CACJ,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;IACnC,CAAC;IAED;;;OAGG;IACH,UAAU;QACR,OAAO,oBAAoB,CAAC,mBAAmB,EAAE,CAAC;IACpD,CAAC;IAED;;;;OAIG;IACH,WAAW;QACT,OAAO,oBAAoB,CAAC,oBAAoB,EAAE,CAAC;IACrD,CAAC;IAED;;;OAGG;IACH,kBAAkB;QAChB,OAAO,oBAAoB,CAAC,2BAA2B,EAAE,CAAC;IAC5D,CAAC;IAED;;;;;;;;;OASG;IACH,WAAW,CACT,KAAQ,EACR,QAEyC;QAEzC,MAAM,WAAW,GACf,KAAK,KAAK,uBAAuB;YAC/B,CAAC,CAAC,yBAAyB;YAC3B,CAAC,CAAC,mBAAmB,CAAC;QAC1B,OAAO,oBAAoB,CAAC,WAAW,CACrC,WAAW,EACX,QAAoC,CAChB,CAAC;IACzB,CAAC;CACO,CAAC","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\n\nimport ExpoSpotifySDKModule from \"../ExpoSpotifySDKModule\";\nimport { AppRemoteError, type AppRemoteErrorCode } from \"./error\";\nimport { createNativeErrorRethrow } from \"../internal/native-errors\";\n\nexport type { AppRemoteErrorCode } from \"./error\";\nexport { AppRemoteError } from \"./error\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Current state of the IPC connection to the Spotify app. */\nexport type ConnectionState = \"disconnected\" | \"connecting\" | \"connected\";\n\n/** Payload of the `connectionStateChange` event. */\nexport interface ConnectionStateChangeEvent {\n state: ConnectionState;\n}\n\n/** Payload of the `connectionError` event. */\nexport interface ConnectionErrorEvent {\n code: AppRemoteErrorCode;\n message: string;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst rethrowAsAppRemoteError = createNativeErrorRethrow({\n ErrorClass: AppRemoteError,\n unknownCode: \"UNKNOWN\",\n validCodes: new Set<AppRemoteErrorCode>([\n \"CONNECTION_FAILED\",\n \"CONNECTION_LOST\",\n \"NOT_CONNECTED\",\n \"UNKNOWN\",\n ]),\n});\n\n// ---------------------------------------------------------------------------\n// AppRemote namespace\n// ---------------------------------------------------------------------------\n\n/**\n * Spotify App Remote namespace. Manages the IPC connection to the running\n * Spotify app. All `Player`, `User`, `Content`, and `Images` calls require an\n * active connection established via `AppRemote.connect()`.\n *\n * @example\n * ```ts\n * import { AppRemote } from \"@wwdrew/expo-spotify-sdk\";\n *\n * // Connect using the access token from Auth.authenticate()\n * await AppRemote.connect(session.accessToken);\n *\n * const sub = AppRemote.addListener(\"connectionStateChange\", ({ state }) => {\n * console.log(\"connection state:\", state);\n * });\n *\n * // later:\n * await AppRemote.disconnect();\n * sub.remove();\n * ```\n */\nexport const AppRemote = {\n /**\n * Opens a connection to the running Spotify app using the provided access\n * token. Resolves when the connection is established; rejects with an\n * {@link AppRemoteError} if the connection fails.\n *\n * **Android note:** The access token is accepted for API parity with iOS but\n * the Android App Remote SDK does not accept it directly — it uses the\n * session cached in the Spotify app from your earlier `Auth.authenticate()`\n * call. Ensure `Auth.authenticate()` has succeeded before calling `connect()`.\n *\n * Calling `connect()` while already connected is a no-op.\n */\n connect(accessToken: string): Promise<void> {\n return ExpoSpotifySDKModule.appRemoteConnect(accessToken).catch(\n rethrowAsAppRemoteError,\n );\n },\n\n /**\n * Wakes Spotify (launching if suspended), starts or resumes playback, and\n * connects. Use when {@link connect} may fail because Spotify is not running.\n * Always foregrounds Spotify and starts audio — see package api-reference.\n *\n * @param accessToken Access token from `Auth.authenticate()`.\n * @param uri Spotify URI to play, or empty (default) to resume last/contextual track.\n */\n authorizeAndPlay(accessToken: string, uri: string = \"\"): Promise<void> {\n return ExpoSpotifySDKModule.appRemoteAuthorizeAndPlay(\n accessToken,\n uri,\n ).catch(rethrowAsAppRemoteError);\n },\n\n /**\n * Disconnects from the Spotify app. Safe to call when already disconnected.\n * Resolves once the disconnection is complete.\n */\n disconnect(): Promise<void> {\n return ExpoSpotifySDKModule.appRemoteDisconnect();\n },\n\n /**\n * Returns `true` if currently connected to the Spotify app.\n * This is a synchronous snapshot — subscribe to `\"connectionStateChange\"`\n * for reactive updates.\n */\n isConnected(): boolean {\n return ExpoSpotifySDKModule.appRemoteIsConnected();\n },\n\n /**\n * Returns the current {@link ConnectionState} synchronously.\n * Subscribe to `\"connectionStateChange\"` for reactive updates.\n */\n getConnectionState(): Promise<ConnectionState> {\n return ExpoSpotifySDKModule.appRemoteGetConnectionState();\n },\n\n /**\n * Subscribes to connection lifecycle events.\n *\n * | Event | Payload | When |\n * |---|---|---|\n * | `\"connectionStateChange\"` | `{ state: ConnectionState }` | State transitions |\n * | `\"connectionError\"` | `{ code, message }` | Connection failures and drops |\n *\n * Returns a `Subscription` — call `.remove()` to unsubscribe.\n */\n addListener<E extends \"connectionStateChange\" | \"connectionError\">(\n event: E,\n listener: E extends \"connectionStateChange\"\n ? (event: ConnectionStateChangeEvent) => void\n : (event: ConnectionErrorEvent) => void,\n ): EventSubscription {\n const nativeEvent =\n event === \"connectionStateChange\"\n ? \"onConnectionStateChange\"\n : \"onConnectionError\";\n return ExpoSpotifySDKModule.addListener(\n nativeEvent,\n listener as (event: unknown) => void,\n ) as EventSubscription;\n },\n} as const;\n"]}
@@ -2,7 +2,8 @@
2
2
  "platforms": ["ios", "android", "web"],
3
3
  "ios": {
4
4
  "modules": ["ExpoSpotifySDKModule"],
5
- "appDelegateSubscribers": ["ExpoSpotifyAppDelegate"]
5
+ "appDelegateSubscribers": ["ExpoSpotifyAppDelegate"],
6
+ "podspecPath": "ios/ExpoSpotifySDK.podspec"
6
7
  },
7
8
  "android": {
8
9
  "modules": ["expo.modules.spotifysdk.ExpoSpotifySDKModule"]
@@ -1,18 +1,6 @@
1
1
  require 'json'
2
2
 
3
- absolute_react_native_path = ''
4
- if !ENV['REACT_NATIVE_PATH'].nil?
5
- absolute_react_native_path = File.expand_path(ENV['REACT_NATIVE_PATH'], Pod::Config.instance.project_root)
6
- else
7
- absolute_react_native_path = File.dirname(`node --print "require.resolve('react-native/package.json')"`)
8
- end
9
-
10
- unless defined?(spm_dependency)
11
- require File.join(absolute_react_native_path, "scripts/react_native_pods")
12
- end
13
-
14
3
  package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
15
- spotify_native = JSON.parse(File.read(File.join(__dir__, 'spotify-native-sdk-versions.json')))
16
4
 
17
5
  Pod::Spec.new do |s|
18
6
  s.name = 'ExpoSpotifySDK'
@@ -28,6 +16,7 @@ Pod::Spec.new do |s|
28
16
  s.static_framework = true
29
17
 
30
18
  s.dependency 'ExpoModulesCore'
19
+ s.dependency 'SpotifyiOS'
31
20
 
32
21
  s.pod_target_xcconfig = {
33
22
  'DEFINES_MODULE' => 'YES',
@@ -35,18 +24,4 @@ Pod::Spec.new do |s|
35
24
  }
36
25
 
37
26
  s.source_files = "**/*.{h,m,swift}"
38
-
39
- ios_sdk = spotify_native['ios']
40
- if ios_sdk.nil? ||
41
- ios_sdk['spmRepositoryUrl'].to_s.empty? ||
42
- ios_sdk['spmVersion'].to_s.empty? ||
43
- ios_sdk['spmProduct'].to_s.empty?
44
- raise "ios/spotify-native-sdk-versions.json is missing required iOS fields: spmRepositoryUrl, spmVersion, spmProduct"
45
- end
46
-
47
- spm_dependency(s,
48
- url: ios_sdk['spmRepositoryUrl'],
49
- requirement: { kind: 'exactVersion', version: ios_sdk['spmVersion'] },
50
- products: [ios_sdk['spmProduct']]
51
- )
52
27
  end
@@ -1,7 +1,7 @@
1
1
  import ExpoModulesCore
2
2
  import SpotifyiOS
3
3
 
4
- private let SDK_VERSION = "2.2.0" // x-release-please-version
4
+ private let SDK_VERSION = "2.2.2" // x-release-please-version
5
5
  private let EVENT_SESSION_CHANGE = "onSessionChange"
6
6
  private let EVENT_CONNECTION_STATE_CHANGE = "onConnectionStateChange"
7
7
  private let EVENT_CONNECTION_ERROR = "onConnectionError"
@@ -0,0 +1,221 @@
1
+ import Foundation
2
+ import SpotifyiOS
3
+ import UIKit
4
+
5
+ // MARK: — Connection models
6
+
7
+ enum AppRemoteConnectionKickoff: Equatable {
8
+ case directConnect
9
+ case authorizeAndPlay(uri: String)
10
+ }
11
+
12
+ struct AppRemoteConnectionAttempt {
13
+ let remote: SPTAppRemote
14
+ let kickoff: AppRemoteConnectionKickoff
15
+ }
16
+
17
+ // MARK: — Connection lifecycle
18
+
19
+ extension SpotifyAppRemoteCoordinator {
20
+ func connect(accessToken: String) async throws {
21
+ if appRemote?.isConnected == true { return }
22
+ try await beginConnection(accessToken: accessToken, kickoff: .directConnect)
23
+ }
24
+
25
+ /// Wakes the Spotify app (via `authorizeAndPlayURI`), starts playback, and
26
+ /// then completes the App Remote connection once Spotify redirects back.
27
+ ///
28
+ /// Unlike `connect()`, this works even when the Spotify app has been
29
+ /// suspended: `authorizeAndPlayURI` performs an app-switch to Spotify, which
30
+ /// revives it. Spotify then redirects back to the host app with an access
31
+ /// token; `handleAuthorizeRedirect(_:)` consumes that redirect and calls
32
+ /// `connect()` on the same `SPTAppRemote` instance.
33
+ ///
34
+ /// The `connectContinuation` is reused for the whole flow, so `didConnect()`
35
+ /// / `didFailToConnect(error:)` resolve it exactly as for `connect()`.
36
+ func authorizeAndPlay(uri: String, accessToken: String) async throws {
37
+ if appRemote?.isConnected == true {
38
+ if uri.isEmpty {
39
+ try await playerResume()
40
+ } else {
41
+ try await playerPlay(uri: uri)
42
+ }
43
+ return
44
+ }
45
+ try await beginConnection(
46
+ accessToken: accessToken,
47
+ kickoff: .authorizeAndPlay(uri: uri),
48
+ )
49
+ }
50
+
51
+ /// Consumes a redirect URL produced by an `authorizeAndPlay()` app-switch.
52
+ /// Returns `true` if an authorize flow was pending and the URL was handled
53
+ /// (so the AppDelegate should not also forward it to the auth coordinator).
54
+ nonisolated func handleAuthorizeRedirect(_ url: URL) -> Bool {
55
+ guard case .authorizeAndPlay = connectionAttempt?.kickoff,
56
+ let remote = connectionAttempt?.remote
57
+ else {
58
+ return false
59
+ }
60
+
61
+ return SpotifyMainThread.run {
62
+ let params = remote.authorizationParameters(from: url)
63
+ if let token = params?[SPTAppRemoteAccessTokenKey] {
64
+ remote.connectionParameters.accessToken = token
65
+ clearConnectionAttempt()
66
+ remote.connect()
67
+ return true
68
+ }
69
+ if let errorDescription = params?[SPTAppRemoteErrorDescriptionKey] {
70
+ clearConnectionAttempt()
71
+ Task {
72
+ await self.didFailToConnect(error: NSError(
73
+ domain: "ExpoSpotifySDK",
74
+ code: -2,
75
+ userInfo: [NSLocalizedDescriptionKey: errorDescription]
76
+ ))
77
+ }
78
+ return true
79
+ }
80
+ return false
81
+ }
82
+ }
83
+
84
+ func disconnect() {
85
+ if let cont = connectContinuation {
86
+ connectContinuation = nil
87
+ cont.resume(throwing: AppRemoteError.connectionFailed("Disconnected before connection completed"))
88
+ }
89
+ teardownPlayerSubscription()
90
+ teardownCapabilitiesSubscription()
91
+ appRemote?.disconnect()
92
+ appRemote = nil
93
+ clearConnectionAttempt()
94
+ transitionState("disconnected")
95
+ }
96
+
97
+ nonisolated func isConnected() -> Bool {
98
+ connectionStateString == "connected"
99
+ }
100
+
101
+ func getConnectionState() -> String {
102
+ connectionStateString
103
+ }
104
+
105
+ func didConnect() {
106
+ clearConnectionAttempt()
107
+ transitionState("connected")
108
+ setupPlayerSubscription()
109
+ setupCapabilitiesSubscription()
110
+ let cont = connectContinuation
111
+ connectContinuation = nil
112
+ cont?.resume()
113
+ }
114
+
115
+ func didFailToConnect(error: Error?) {
116
+ appRemote = nil
117
+ clearConnectionAttempt()
118
+ let msg = error.map { describeNSError($0 as NSError) } ?? "Unknown connection failure"
119
+ let remoteError = AppRemoteError.connectionFailed(msg)
120
+ transitionState("disconnected")
121
+ onConnectionError?(remoteError.code, remoteError.message)
122
+ let cont = connectContinuation
123
+ connectContinuation = nil
124
+ cont?.resume(throwing: remoteError)
125
+ }
126
+
127
+ func didDisconnect(error: Error?) {
128
+ appRemote = nil
129
+ transitionState("disconnected")
130
+ if let error = error {
131
+ let msg = describeNSError(error as NSError)
132
+ onConnectionError?(AppRemoteError.connectionLost(msg).code, msg)
133
+ }
134
+ }
135
+
136
+ // MARK: — Private connection helpers
137
+
138
+ private func beginConnection(
139
+ accessToken: String,
140
+ kickoff: AppRemoteConnectionKickoff,
141
+ ) async throws {
142
+ guard connectContinuation == nil else {
143
+ throw AppRemoteError.connectionFailed("A connection attempt is already in progress")
144
+ }
145
+
146
+ transitionState("connecting")
147
+
148
+ let remote = makeAppRemote(accessToken: accessToken)
149
+ appRemote = remote
150
+ connectionAttempt = AppRemoteConnectionAttempt(remote: remote, kickoff: kickoff)
151
+
152
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
153
+ connectContinuation = cont
154
+ Task { @MainActor in
155
+ switch kickoff {
156
+ case .directConnect:
157
+ remote.connect()
158
+ case .authorizeAndPlay(let uri):
159
+ let success = await remote.authorizeAndPlayURI(uri)
160
+ if !success {
161
+ await self.didFailToConnect(error: Self.authorizeOpenFailedError())
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ private func makeAppRemote(accessToken: String) -> SPTAppRemote {
169
+ let params = SPTAppRemoteConnectionParams(
170
+ accessToken: accessToken,
171
+ defaultImageSize: CGSize.zero,
172
+ imageFormat: .any
173
+ )
174
+ let remote = SPTAppRemote(
175
+ configuration: sptConfiguration,
176
+ connectionParameters: params,
177
+ logLevel: .error
178
+ )
179
+ remote.delegate = connectionBridge
180
+ return remote
181
+ }
182
+
183
+ nonisolated private func clearConnectionAttempt() {
184
+ connectionAttempt = nil
185
+ }
186
+
187
+ private static func authorizeOpenFailedError() -> NSError {
188
+ NSError(
189
+ domain: "ExpoSpotifySDK",
190
+ code: -1,
191
+ userInfo: [
192
+ NSLocalizedDescriptionKey:
193
+ "authorizeAndPlay: could not open the Spotify app (is it installed?)",
194
+ ]
195
+ )
196
+ }
197
+ }
198
+
199
+ // MARK: — Connection delegate bridge
200
+
201
+ /// `SPTAppRemoteDelegate` requires `NSObject` conformance, which an `actor`
202
+ /// cannot satisfy directly. This bridge is a tiny `NSObject` that hops each
203
+ /// delegate callback onto the actor's executor via a `Task`.
204
+ final class SpotifyAppRemoteDelegateBridge: NSObject, SPTAppRemoteDelegate {
205
+ weak var coordinator: SpotifyAppRemoteCoordinator?
206
+
207
+ func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) {
208
+ NSLog("[ExpoSpotifySDK] AppRemote: connection established")
209
+ Task { await coordinator?.didConnect() }
210
+ }
211
+
212
+ func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: (any Error)?) {
213
+ NSLog("[ExpoSpotifySDK] AppRemote: connection failed — %@", String(describing: error))
214
+ Task { await coordinator?.didFailToConnect(error: error) }
215
+ }
216
+
217
+ func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: (any Error)?) {
218
+ NSLog("[ExpoSpotifySDK] AppRemote: disconnected — %@", String(describing: error))
219
+ Task { await coordinator?.didDisconnect(error: error) }
220
+ }
221
+ }
@@ -18,20 +18,16 @@ import UIKit
18
18
  actor SpotifyAppRemoteCoordinator {
19
19
  static let shared: SpotifyAppRemoteCoordinator? = SpotifyAppRemoteCoordinator.create()
20
20
 
21
- private let sptConfiguration: SPTConfiguration
22
- private let connectionBridge: SpotifyAppRemoteDelegateBridge
21
+ let sptConfiguration: SPTConfiguration
22
+ let connectionBridge: SpotifyAppRemoteDelegateBridge
23
23
  private let playerStateBridge: SpotifyPlayerStateDelegateBridge
24
24
  private let userCapabilitiesBridge: SpotifyUserCapabilitiesDelegateBridge
25
- private var appRemote: SPTAppRemote?
26
- private var connectContinuation: CheckedContinuation<Void, Error>?
25
+ var appRemote: SPTAppRemote?
26
+ var connectContinuation: CheckedContinuation<Void, Error>?
27
27
 
28
- /// The `SPTAppRemote` awaiting an `authorizeAndPlay()` redirect, exposed
29
- /// synchronously to the URL-redirect path. Mirrors the auth coordinator's
30
- /// `nonisolated(unsafe)` `sessionManager`: it is only written from the
31
- /// actor's executor (or the main-thread redirect handler) and read on the
32
- /// main thread, so occasional cross-actor access is acceptable. `nil`
33
- /// whenever no authorize-and-play flow is in flight.
34
- nonisolated(unsafe) private var pendingAuthorizeRemote: SPTAppRemote?
28
+ /// In-flight connection attempt, readable from the synchronous redirect path.
29
+ /// `nil` whenever no connection attempt is active.
30
+ nonisolated(unsafe) var connectionAttempt: AppRemoteConnectionAttempt?
35
31
 
36
32
  nonisolated(unsafe) private(set) var connectionStateString: String = "disconnected"
37
33
 
@@ -76,195 +72,15 @@ actor SpotifyAppRemoteCoordinator {
76
72
  return SpotifyAppRemoteCoordinator(sptConfiguration: sptConfig)
77
73
  }
78
74
 
79
- // MARK: — Connection lifecycle
80
-
81
- func connect(accessToken: String) async throws {
82
- if appRemote?.isConnected == true { return }
83
- guard connectContinuation == nil else {
84
- throw AppRemoteError.connectionFailed("A connection attempt is already in progress")
85
- }
86
-
87
- transitionState("connecting")
88
-
89
- let params = SPTAppRemoteConnectionParams(
90
- accessToken: accessToken,
91
- defaultImageSize: CGSize.zero,
92
- imageFormat: .any
93
- )
94
- let remote = SPTAppRemote(
95
- configuration: sptConfiguration,
96
- connectionParameters: params,
97
- logLevel: .error
98
- )
99
- remote.delegate = connectionBridge
100
- appRemote = remote
101
-
102
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
103
- self.connectContinuation = cont
104
- Task { @MainActor in remote.connect() }
105
- }
106
- }
107
-
108
- /// Wakes the Spotify app (via `authorizeAndPlayURI`), starts playback, and
109
- /// then completes the App Remote connection once Spotify redirects back.
110
- ///
111
- /// Unlike `connect()`, this works even when the Spotify app has been
112
- /// suspended: `authorizeAndPlayURI` performs an app-switch to Spotify, which
113
- /// revives it. Spotify then redirects back to the host app with an access
114
- /// token; `handleAuthorizeRedirect(_:)` consumes that redirect and calls
115
- /// `connect()` on the same `SPTAppRemote` instance.
116
- ///
117
- /// The `connectContinuation` is reused for the whole flow, so `didConnect()`
118
- /// / `didFailToConnect(error:)` resolve it exactly as for `connect()`.
119
- func authorizeAndPlay(uri: String, accessToken: String) async throws {
120
- if appRemote?.isConnected == true {
121
- // Already connected — honour the request by (re)starting playback.
122
- if uri.isEmpty {
123
- try await playerResume()
124
- } else {
125
- try await playerPlay(uri: uri)
126
- }
127
- return
128
- }
129
- guard connectContinuation == nil else {
130
- throw AppRemoteError.connectionFailed("A connection attempt is already in progress")
131
- }
132
-
133
- transitionState("connecting")
134
-
135
- let params = SPTAppRemoteConnectionParams(
136
- accessToken: accessToken,
137
- defaultImageSize: CGSize.zero,
138
- imageFormat: .any
139
- )
140
- let remote = SPTAppRemote(
141
- configuration: sptConfiguration,
142
- connectionParameters: params,
143
- logLevel: .error
144
- )
145
- remote.delegate = connectionBridge
146
- appRemote = remote
147
- pendingAuthorizeRemote = remote
148
-
149
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
150
- self.connectContinuation = cont
151
- Task { @MainActor in
152
- // `authorizeAndPlayURI` app-switches to Spotify to wake it. `success`
153
- // is false when the Spotify app could not be opened (e.g. not
154
- // installed) — no redirect will arrive, so fail the continuation now.
155
- // On success we await the redirect, handled by
156
- // `handleAuthorizeRedirect(_:)`, which completes the connection.
157
- let success = await remote.authorizeAndPlayURI(uri)
158
- if !success {
159
- await self.didFailToConnect(error: NSError(
160
- domain: "ExpoSpotifySDK",
161
- code: -1,
162
- userInfo: [NSLocalizedDescriptionKey: "authorizeAndPlay: could not open the Spotify app (is it installed?)"]
163
- ))
164
- }
165
- }
166
- }
167
- }
168
-
169
- /// Consumes a redirect URL produced by an `authorizeAndPlay()` app-switch.
170
- /// Returns `true` if an authorize flow was pending and the URL was handled
171
- /// (so the AppDelegate should not also forward it to the auth coordinator).
172
- ///
173
- /// Synchronous + `nonisolated` because the AppDelegate `open url` hook
174
- /// expects an immediate `Bool`, matching `SpotifyAuthCoordinator.handleOpenURL`.
175
- nonisolated func handleAuthorizeRedirect(_ url: URL) -> Bool {
176
- guard let remote = pendingAuthorizeRemote else { return false }
177
- let consume: () -> Bool = { [self] in
178
- MainActor.assumeIsolated {
179
- let params = remote.authorizationParameters(from: url)
180
- if let token = params?[SPTAppRemoteAccessTokenKey] {
181
- remote.connectionParameters.accessToken = token
182
- pendingAuthorizeRemote = nil
183
- remote.connect()
184
- return true
185
- } else if let errorDescription = params?[SPTAppRemoteErrorDescriptionKey] {
186
- pendingAuthorizeRemote = nil
187
- Task {
188
- await self.didFailToConnect(error: NSError(
189
- domain: "ExpoSpotifySDK",
190
- code: -2,
191
- userInfo: [NSLocalizedDescriptionKey: errorDescription]
192
- ))
193
- }
194
- return true
195
- }
196
- return false
197
- }
198
- }
199
- if Thread.isMainThread {
200
- return consume()
201
- }
202
- return DispatchQueue.main.sync(execute: consume)
203
- }
204
-
205
- func disconnect() {
206
- if let cont = connectContinuation {
207
- connectContinuation = nil
208
- cont.resume(throwing: AppRemoteError.connectionFailed("Disconnected before connection completed"))
209
- }
210
- teardownPlayerSubscription()
211
- teardownCapabilitiesSubscription()
212
- appRemote?.disconnect()
213
- appRemote = nil
214
- pendingAuthorizeRemote = nil
215
- transitionState("disconnected")
216
- }
217
-
218
- nonisolated func isConnected() -> Bool {
219
- connectionStateString == "connected"
220
- }
221
-
222
- func getConnectionState() -> String {
223
- connectionStateString
224
- }
225
-
226
- // MARK: — Connection delegate callbacks
227
-
228
- func didConnect() {
229
- pendingAuthorizeRemote = nil
230
- transitionState("connected")
231
- setupPlayerSubscription()
232
- setupCapabilitiesSubscription()
233
- let cont = connectContinuation
234
- connectContinuation = nil
235
- cont?.resume()
236
- }
237
-
238
- func didFailToConnect(error: Error?) {
239
- appRemote = nil
240
- pendingAuthorizeRemote = nil
241
- let msg = error.map { describeNSError($0 as NSError) } ?? "Unknown connection failure"
242
- let remoteError = AppRemoteError.connectionFailed(msg)
243
- transitionState("disconnected")
244
- onConnectionError?(remoteError.code, remoteError.message)
245
- let cont = connectContinuation
246
- connectContinuation = nil
247
- cont?.resume(throwing: remoteError)
248
- }
249
-
250
- func didDisconnect(error: Error?) {
251
- appRemote = nil
252
- transitionState("disconnected")
253
- if let error = error {
254
- let msg = describeNSError(error as NSError)
255
- onConnectionError?(AppRemoteError.connectionLost(msg).code, msg)
256
- }
257
- }
258
-
259
75
  // MARK: — Player subscription
260
76
 
261
- private func setupPlayerSubscription() {
77
+ func setupPlayerSubscription() {
262
78
  guard let playerAPI = appRemote?.playerAPI else { return }
263
79
  playerAPI.delegate = playerStateBridge
264
80
  playerAPI.subscribe(toPlayerState: { _, _ in })
265
81
  }
266
82
 
267
- private func teardownPlayerSubscription() {
83
+ func teardownPlayerSubscription() {
268
84
  guard let playerAPI = appRemote?.playerAPI else { return }
269
85
  playerAPI.delegate = nil
270
86
  playerAPI.unsubscribe(toPlayerState: { _, _ in })
@@ -277,13 +93,13 @@ actor SpotifyAppRemoteCoordinator {
277
93
 
278
94
  // MARK: — User subscription
279
95
 
280
- private func setupCapabilitiesSubscription() {
96
+ func setupCapabilitiesSubscription() {
281
97
  guard let userAPI = appRemote?.userAPI else { return }
282
98
  userAPI.delegate = userCapabilitiesBridge
283
99
  userAPI.subscribe(toCapabilityChanges: { _, _ in })
284
100
  }
285
101
 
286
- private func teardownCapabilitiesSubscription() {
102
+ func teardownCapabilitiesSubscription() {
287
103
  guard let userAPI = appRemote?.userAPI else { return }
288
104
  userAPI.delegate = nil
289
105
  userAPI.unsubscribe(toCapabilityChanges: { _, _ in })
@@ -598,12 +414,12 @@ actor SpotifyAppRemoteCoordinator {
598
414
  }
599
415
  }
600
416
 
601
- private func transitionState(_ state: String) {
417
+ func transitionState(_ state: String) {
602
418
  connectionStateString = state
603
419
  onConnectionStateChange?(state)
604
420
  }
605
421
 
606
- private func describeNSError(_ error: NSError) -> String {
422
+ func describeNSError(_ error: NSError) -> String {
607
423
  var parts: [String] = ["\(error.domain) code \(error.code)"]
608
424
  let desc = error.localizedDescription
609
425
  if !desc.isEmpty { parts.append("\"\(desc)\"") }
@@ -614,30 +430,6 @@ actor SpotifyAppRemoteCoordinator {
614
430
  }
615
431
  }
616
432
 
617
- // MARK: — Connection delegate bridge
618
-
619
- /// `SPTAppRemoteDelegate` requires `NSObject` conformance, which an `actor`
620
- /// cannot satisfy directly. This bridge is a tiny `NSObject` that hops each
621
- /// delegate callback onto the actor's executor via a `Task`.
622
- final class SpotifyAppRemoteDelegateBridge: NSObject, SPTAppRemoteDelegate {
623
- weak var coordinator: SpotifyAppRemoteCoordinator?
624
-
625
- func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) {
626
- NSLog("[ExpoSpotifySDK] AppRemote: connection established")
627
- Task { await coordinator?.didConnect() }
628
- }
629
-
630
- func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: (any Error)?) {
631
- NSLog("[ExpoSpotifySDK] AppRemote: connection failed — %@", String(describing: error))
632
- Task { await coordinator?.didFailToConnect(error: error) }
633
- }
634
-
635
- func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: (any Error)?) {
636
- NSLog("[ExpoSpotifySDK] AppRemote: disconnected — %@", String(describing: error))
637
- Task { await coordinator?.didDisconnect(error: error) }
638
- }
639
- }
640
-
641
433
  // MARK: — Player state delegate bridge
642
434
 
643
435
  /// `SPTAppRemotePlayerStateDelegate` requires `NSObject` conformance. This
@@ -1,6 +1,7 @@
1
1
  import ExpoModulesCore
2
2
  import Foundation
3
3
  import SpotifyiOS
4
+ import UIKit
4
5
 
5
6
  /// Public, structured errors thrown by the coordinator. Each case carries the
6
7
  /// JS-facing error code in `code`, mirroring the Android `CodedException`
@@ -105,15 +106,9 @@ actor SpotifyAuthCoordinator {
105
106
  /// Synchronous URL-handler dispatcher used by the AppDelegate / SceneDelegate
106
107
  /// subscribers to forward redirects to the SPTSessionManager.
107
108
  nonisolated func handleOpenURL(_ url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {
108
- let openSession: () -> Bool = { [self] in
109
- MainActor.assumeIsolated {
110
- sessionManager.application(UIApplication.shared, open: url, options: options)
111
- }
112
- }
113
- if Thread.isMainThread {
114
- return openSession()
109
+ SpotifyMainThread.run {
110
+ sessionManager.application(UIApplication.shared, open: url, options: options)
115
111
  }
116
- return DispatchQueue.main.sync(execute: openSession)
117
112
  }
118
113
 
119
114
  func authenticate(
@@ -0,0 +1,14 @@
1
+ import Foundation
2
+
3
+ /// Runs a block on the main thread, matching AppDelegate / SceneDelegate redirect
4
+ /// hooks that require a synchronous result from the main actor.
5
+ enum SpotifyMainThread {
6
+ static func run<T>(_ block: @MainActor () -> T) -> T {
7
+ if Thread.isMainThread {
8
+ return MainActor.assumeIsolated(block)
9
+ }
10
+ return DispatchQueue.main.sync {
11
+ MainActor.assumeIsolated(block)
12
+ }
13
+ }
14
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "ios": {
3
- "spmRepositoryUrl": "https://github.com/spotify/ios-sdk.git",
4
- "spmVersion": "5.0.1",
5
- "spmProduct": "SpotifyiOS"
3
+ "version": "5.0.1",
4
+ "tarballSha256": "acafc07d35ce3f0bce93cac0031cd1cca1ba7ba6647a3e07e123f2e954ec298b",
5
+ "binarySha256": "988704167a3839136c7a4fd83742fca1422dfad48de41354ed07aa8e47611e32"
6
6
  },
7
7
  "android": {
8
8
  "appRemoteVersion": "0.8.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwdrew/expo-spotify-sdk",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Expo module wrapping the native Spotify iOS (v5) and Android (v4) SDKs for OAuth authentication and App Remote playback control",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -20,7 +20,7 @@
20
20
  "clean": "rm -rf build plugin/build android/build",
21
21
  "lint": "eslint src",
22
22
  "test": "jest --config jest.config.js",
23
- "prepublishOnly": "yarn clean && yarn build && yarn build:plugin",
23
+ "prepublishOnly": "yarn clean && yarn build && yarn build:plugin && bash scripts/verify-npm-pack.sh",
24
24
  "typecheck": "tsc --noEmit",
25
25
  "open:ios": "open -a \"Xcode\" example/ios",
26
26
  "open:android": "open -a \"Android Studio\" example/android"
@@ -43,6 +43,7 @@
43
43
  "android/src",
44
44
  "android/libs/SETUP.md",
45
45
  "ios/spotify-native-sdk-versions.json",
46
+ "spotify-ios/SpotifyiOS.podspec",
46
47
  "ios/*.swift",
47
48
  "ios/*.podspec",
48
49
  "app.plugin.js",
@@ -0,0 +1,9 @@
1
+ import { ConfigPlugin } from "@expo/config-plugins";
2
+ export declare const PODFILE_SPOTIFY_IOS_TAG = "expo-spotify-sdk-spotify-ios-pod";
3
+ /**
4
+ * Standard CocoaPods binary pod — SpotifyiOS.podspec uses `source: { http: ... }`
5
+ * so CocoaPods downloads and runs `prepare_command` (unlike `:path` pods).
6
+ */
7
+ export declare const PODFILE_SPOTIFY_IOS_POD = " pod 'SpotifyiOS', :podspec => File.join(__dir__, '../node_modules/@wwdrew/expo-spotify-sdk/spotify-ios/SpotifyiOS.podspec')";
8
+ export declare function applySpotifyIosPod(podfileContents: string): string;
9
+ export declare const withSpotifyIosPod: ConfigPlugin;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withSpotifyIosPod = exports.PODFILE_SPOTIFY_IOS_POD = exports.PODFILE_SPOTIFY_IOS_TAG = void 0;
4
+ exports.applySpotifyIosPod = applySpotifyIosPod;
5
+ const config_plugins_1 = require("@expo/config-plugins");
6
+ const generateCode_1 = require("@expo/config-plugins/build/utils/generateCode");
7
+ exports.PODFILE_SPOTIFY_IOS_TAG = "expo-spotify-sdk-spotify-ios-pod";
8
+ /**
9
+ * Standard CocoaPods binary pod — SpotifyiOS.podspec uses `source: { http: ... }`
10
+ * so CocoaPods downloads and runs `prepare_command` (unlike `:path` pods).
11
+ */
12
+ exports.PODFILE_SPOTIFY_IOS_POD = ` pod 'SpotifyiOS', :podspec => File.join(__dir__, '../node_modules/@wwdrew/expo-spotify-sdk/spotify-ios/SpotifyiOS.podspec')`;
13
+ function applySpotifyIosPod(podfileContents) {
14
+ const merged = (0, generateCode_1.mergeContents)({
15
+ tag: exports.PODFILE_SPOTIFY_IOS_TAG,
16
+ src: podfileContents,
17
+ newSrc: exports.PODFILE_SPOTIFY_IOS_POD,
18
+ anchor: /use_expo_modules!/,
19
+ offset: 1,
20
+ comment: "#",
21
+ });
22
+ return merged.didMerge || merged.didClear
23
+ ? merged.contents
24
+ : podfileContents;
25
+ }
26
+ const withSpotifyIosPod = (config) => {
27
+ return (0, config_plugins_1.withPodfile)(config, (config) => {
28
+ config.modResults.contents = applySpotifyIosPod(config.modResults.contents);
29
+ return config;
30
+ });
31
+ };
32
+ exports.withSpotifyIosPod = withSpotifyIosPod;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withSpotifySdkConfig = void 0;
4
4
  const withSpotifyAndroidAppBuildGradle_1 = require("./android/withSpotifyAndroidAppBuildGradle");
5
5
  const withSpotifyConfigValues_1 = require("./ios/withSpotifyConfigValues");
6
+ const withSpotifyIosPod_1 = require("./ios/withSpotifyIosPod");
6
7
  const withSpotifyQueryScheme_1 = require("./ios/withSpotifyQueryScheme");
7
8
  const withSpotifyURLScheme_1 = require("./ios/withSpotifyURLScheme");
8
9
  const withSpotifySdkConfig = (config, spotifyConfig) => {
@@ -18,6 +19,7 @@ const withSpotifySdkConfig = (config, spotifyConfig) => {
18
19
  // Android specific
19
20
  config = (0, withSpotifyAndroidAppBuildGradle_1.withSpotifyAndroidAppBuildGradle)(config, spotifyConfig);
20
21
  // iOS specific
22
+ config = (0, withSpotifyIosPod_1.withSpotifyIosPod)(config);
21
23
  config = (0, withSpotifyConfigValues_1.withSpotifyConfigValues)(config, spotifyConfig);
22
24
  config = (0, withSpotifyQueryScheme_1.withSpotifyQueryScheme)(config, spotifyConfig);
23
25
  config = (0, withSpotifyURLScheme_1.withSpotifyURLScheme)(config, spotifyConfig);
@@ -0,0 +1,37 @@
1
+ require 'json'
2
+
3
+ pins = JSON.parse(File.read(File.join(__dir__, '..', 'ios', 'spotify-native-sdk-versions.json')))['ios']
4
+ version = pins['version']
5
+ tag = "v#{version}"
6
+
7
+ Pod::Spec.new do |s|
8
+ s.name = 'SpotifyiOS'
9
+ s.version = version
10
+ s.summary = 'Spotify iOS SDK (binary pod for @wwdrew/expo-spotify-sdk)'
11
+ s.homepage = 'https://github.com/spotify/ios-sdk'
12
+ s.license = { :type => 'Proprietary' }
13
+ s.author = 'Spotify'
14
+ s.platform = :ios, '16.4'
15
+ s.static_framework = true
16
+
17
+ s.source = {
18
+ :http => "https://github.com/spotify/ios-sdk/archive/refs/tags/#{tag}.tar.gz",
19
+ }
20
+
21
+ s.prepare_command = <<-CMD
22
+ set -e
23
+ if [ ! -d SpotifyiOS.xcframework ]; then
24
+ framework="$(find . -name 'SpotifyiOS.xcframework' -type d | head -1)"
25
+ test -n "$framework"
26
+ mv "$framework" .
27
+ fi
28
+ if [ ! -d Licenses ]; then
29
+ licenses="$(find . -path '*/Licenses' -type d | head -1)"
30
+ if [ -n "$licenses" ]; then mv "$licenses" .; fi
31
+ fi
32
+ find . -mindepth 1 -maxdepth 1 ! -name 'SpotifyiOS.xcframework' ! -name 'Licenses' -exec rm -rf {} +
33
+ CMD
34
+
35
+ s.vendored_frameworks = 'SpotifyiOS.xcframework'
36
+ s.preserve_paths = 'Licenses'
37
+ end