@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 +3 -2
- package/android/spotify-native-sdk.gradle +1 -3
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +1 -1
- package/build/app-remote/index.d.ts +5 -30
- package/build/app-remote/index.d.ts.map +1 -1
- package/build/app-remote/index.js +5 -30
- package/build/app-remote/index.js.map +1 -1
- package/expo-module.config.json +2 -1
- package/ios/ExpoSpotifySDK.podspec +1 -26
- package/ios/ExpoSpotifySDKModule.swift +1 -1
- package/ios/SpotifyAppRemoteConnection.swift +221 -0
- package/ios/SpotifyAppRemoteCoordinator.swift +13 -221
- package/ios/SpotifyAuthCoordinator.swift +3 -8
- package/ios/SpotifyMainThread.swift +14 -0
- package/ios/spotify-native-sdk-versions.json +3 -3
- package/package.json +3 -2
- package/plugin/build/ios/withSpotifyIosPod.d.ts +9 -0
- package/plugin/build/ios/withSpotifyIosPod.js +32 -0
- package/plugin/build/withSpotifySdkConfig.js +2 -0
- package/spotify-ios/SpotifyiOS.podspec +37 -0
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`
|
|
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
|
|
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:
|
|
@@ -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.
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
*
|
|
58
|
-
* Spotify
|
|
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
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
65
|
-
* Spotify
|
|
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
|
|
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"]}
|
package/expo-module.config.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
22
|
-
|
|
21
|
+
let sptConfiguration: SPTConfiguration
|
|
22
|
+
let connectionBridge: SpotifyAppRemoteDelegateBridge
|
|
23
23
|
private let playerStateBridge: SpotifyPlayerStateDelegateBridge
|
|
24
24
|
private let userCapabilitiesBridge: SpotifyUserCapabilitiesDelegateBridge
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
var appRemote: SPTAppRemote?
|
|
26
|
+
var connectContinuation: CheckedContinuation<Void, Error>?
|
|
27
27
|
|
|
28
|
-
///
|
|
29
|
-
///
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
+
func transitionState(_ state: String) {
|
|
602
418
|
connectionStateString = state
|
|
603
419
|
onConnectionStateChange?(state)
|
|
604
420
|
}
|
|
605
421
|
|
|
606
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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.
|
|
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
|