@wwdrew/expo-spotify-sdk 0.7.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +388 -108
- package/android/build.gradle +10 -0
- package/android/consumer-rules.pro +5 -0
- package/android/libs/SETUP.md +29 -0
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +149 -12
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +556 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyConfig.kt +1 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +112 -0
- package/build/ExpoSpotifySDK.types.d.ts +12 -0
- package/build/ExpoSpotifySDK.types.d.ts.map +1 -1
- package/build/ExpoSpotifySDK.types.js.map +1 -1
- package/build/ExpoSpotifySDKModule.web.d.ts +23 -0
- package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
- package/build/ExpoSpotifySDKModule.web.js +74 -5
- package/build/ExpoSpotifySDKModule.web.js.map +1 -1
- package/build/app-remote/error.d.ts +8 -0
- package/build/app-remote/error.d.ts.map +1 -0
- package/build/app-remote/error.js +10 -0
- package/build/app-remote/error.js.map +1 -0
- package/build/app-remote/index.d.ts +79 -0
- package/build/app-remote/index.d.ts.map +1 -0
- package/build/app-remote/index.js +110 -0
- package/build/app-remote/index.js.map +1 -0
- package/build/auth/error.d.ts +8 -0
- package/build/auth/error.d.ts.map +1 -0
- package/build/auth/error.js +10 -0
- package/build/auth/error.js.map +1 -0
- package/build/auth/index.d.ts +124 -0
- package/build/auth/index.d.ts.map +1 -0
- package/build/auth/index.js +162 -0
- package/build/auth/index.js.map +1 -0
- package/build/content/error.d.ts +8 -0
- package/build/content/error.d.ts.map +1 -0
- package/build/content/error.js +10 -0
- package/build/content/error.js.map +1 -0
- package/build/content/index.d.ts +38 -0
- package/build/content/index.d.ts.map +1 -0
- package/build/content/index.js +56 -0
- package/build/content/index.js.map +1 -0
- package/build/error.d.ts +24 -0
- package/build/error.d.ts.map +1 -0
- package/build/error.js +25 -0
- package/build/error.js.map +1 -0
- package/build/hooks/index.d.ts +90 -0
- package/build/hooks/index.d.ts.map +1 -0
- package/build/hooks/index.js +326 -0
- package/build/hooks/index.js.map +1 -0
- package/build/images/error.d.ts +8 -0
- package/build/images/error.d.ts.map +1 -0
- package/build/images/error.js +10 -0
- package/build/images/error.js.map +1 -0
- package/build/images/index.d.ts +36 -0
- package/build/images/index.d.ts.map +1 -0
- package/build/images/index.js +59 -0
- package/build/images/index.js.map +1 -0
- package/build/index.d.ts +44 -42
- package/build/index.d.ts.map +1 -1
- package/build/index.js +49 -122
- package/build/index.js.map +1 -1
- package/build/player/error.d.ts +8 -0
- package/build/player/error.d.ts.map +1 -0
- package/build/player/error.js +10 -0
- package/build/player/error.js.map +1 -0
- package/build/player/index.d.ts +140 -0
- package/build/player/index.d.ts.map +1 -0
- package/build/player/index.js +136 -0
- package/build/player/index.js.map +1 -0
- package/build/uri/index.d.ts +55 -0
- package/build/uri/index.d.ts.map +1 -0
- package/build/uri/index.js +57 -0
- package/build/uri/index.js.map +1 -0
- package/build/user/error.d.ts +8 -0
- package/build/user/error.d.ts.map +1 -0
- package/build/user/error.js +10 -0
- package/build/user/error.js.map +1 -0
- package/build/user/index.d.ts +44 -0
- package/build/user/index.d.ts.map +1 -0
- package/build/user/index.js +97 -0
- package/build/user/index.js.map +1 -0
- package/ios/ExpoSpotifySDKModule.swift +233 -6
- package/ios/SPTScopeSerializer.swift +5 -0
- package/ios/SpotifyAppRemoteCoordinator.swift +939 -0
- package/ios/SpotifyAuthCoordinator.swift +67 -13
- package/ios/SpotifyTokenRefreshClient.swift +32 -1
- package/package.json +4 -3
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
package expo.modules.spotifysdk
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import com.spotify.android.appremote.api.ConnectionParams
|
|
6
|
+
import com.spotify.android.appremote.api.ContentApi
|
|
7
|
+
import com.spotify.android.appremote.api.Connector
|
|
8
|
+
import com.spotify.android.appremote.api.SpotifyAppRemote
|
|
9
|
+
import com.spotify.protocol.client.CallResult
|
|
10
|
+
import com.spotify.protocol.client.Subscription
|
|
11
|
+
import com.spotify.protocol.types.Capabilities
|
|
12
|
+
import com.spotify.protocol.types.CrossfadeState
|
|
13
|
+
import com.spotify.protocol.types.Image
|
|
14
|
+
import com.spotify.protocol.types.ImageUri
|
|
15
|
+
import com.spotify.protocol.types.LibraryState
|
|
16
|
+
import com.spotify.protocol.types.ListItem
|
|
17
|
+
import com.spotify.protocol.types.ListItems
|
|
18
|
+
import com.spotify.protocol.types.PlayerState
|
|
19
|
+
import com.spotify.protocol.types.PlaybackSpeed
|
|
20
|
+
import com.spotify.protocol.types.Repeat
|
|
21
|
+
import expo.modules.kotlin.exception.CodedException
|
|
22
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
23
|
+
import kotlinx.coroutines.sync.Mutex
|
|
24
|
+
import java.io.File
|
|
25
|
+
import java.io.FileOutputStream
|
|
26
|
+
import kotlin.coroutines.resume
|
|
27
|
+
import kotlin.coroutines.resumeWithException
|
|
28
|
+
import kotlin.math.abs
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Manages the [SpotifyAppRemote] singleton, the IPC connection lifecycle, and all
|
|
32
|
+
* player transport operations for the running Spotify app.
|
|
33
|
+
*
|
|
34
|
+
* ## Android vs iOS token handling
|
|
35
|
+
*
|
|
36
|
+
* On iOS the access token is passed directly into `SPTAppRemoteConnectionParams`
|
|
37
|
+
* and used by the SDK to authenticate the IPC channel. On Android the Spotify
|
|
38
|
+
* App Remote SDK does not accept a token in [ConnectionParams]; it relies on the
|
|
39
|
+
* token that was cached in the Spotify app during the Auth SDK flow
|
|
40
|
+
* (`showAuthView(false)` suppresses Spotify's own auth UI since the user is
|
|
41
|
+
* already authenticated). The `accessToken` parameter to [connect] is accepted
|
|
42
|
+
* for API parity with iOS but is not forwarded to the Android SDK.
|
|
43
|
+
*/
|
|
44
|
+
class SpotifyAppRemoteCoordinator {
|
|
45
|
+
@Volatile private var appRemote: SpotifyAppRemote? = null
|
|
46
|
+
@Volatile private var connectionState: String = "disconnected"
|
|
47
|
+
private val connectMutex = Mutex()
|
|
48
|
+
|
|
49
|
+
private var playerStateSubscription: Subscription<PlayerState>? = null
|
|
50
|
+
private var capabilitiesSubscription: Subscription<Capabilities>? = null
|
|
51
|
+
@Volatile private var lastTrackUri: String? = null
|
|
52
|
+
@Volatile private var lastNonEmptyTrackName: String? = null
|
|
53
|
+
|
|
54
|
+
/** Injected by the module to forward connection state-change events to JS. */
|
|
55
|
+
var onConnectionStateChange: ((String) -> Unit)? = null
|
|
56
|
+
|
|
57
|
+
/** Injected by the module to forward connection error events to JS. */
|
|
58
|
+
var onConnectionError: ((String, String) -> Unit)? = null
|
|
59
|
+
|
|
60
|
+
/** Injected by the module to forward player state change events to JS. */
|
|
61
|
+
var onPlayerStateChange: ((Map<String, Any?>) -> Unit)? = null
|
|
62
|
+
|
|
63
|
+
/** Injected by the module to forward user capabilities events to JS. */
|
|
64
|
+
var onCapabilitiesChange: ((Map<String, Any?>) -> Unit)? = null
|
|
65
|
+
|
|
66
|
+
fun isConnected(): Boolean = appRemote?.isConnected == true
|
|
67
|
+
|
|
68
|
+
fun getConnectionState(): String = connectionState
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Establishes a connection to the Spotify app.
|
|
72
|
+
*
|
|
73
|
+
* Suspends until [Connector.ConnectionListener.onConnected] or
|
|
74
|
+
* [Connector.ConnectionListener.onFailure] fires. Re-entry while a
|
|
75
|
+
* connection is already active is a no-op; re-entry while a connection is
|
|
76
|
+
* in progress throws [AppRemoteConnectionFailedException].
|
|
77
|
+
*
|
|
78
|
+
* @param context Application or activity context.
|
|
79
|
+
* @param clientId Spotify client ID from the manifest.
|
|
80
|
+
* @param redirectUri Redirect URI registered in the Spotify Dashboard.
|
|
81
|
+
* @param accessToken The access token from [SpotifyAuthCoordinator] (iOS-parity
|
|
82
|
+
* parameter; not forwarded to the Android SDK — see class kdoc).
|
|
83
|
+
*/
|
|
84
|
+
suspend fun connect(
|
|
85
|
+
context: Context,
|
|
86
|
+
clientId: String,
|
|
87
|
+
redirectUri: String,
|
|
88
|
+
@Suppress("UNUSED_PARAMETER") accessToken: String,
|
|
89
|
+
) {
|
|
90
|
+
if (appRemote?.isConnected == true) return
|
|
91
|
+
if (!connectMutex.tryLock()) {
|
|
92
|
+
throw AppRemoteConnectionFailedException("A connection attempt is already in progress")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
transitionState("connecting")
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
suspendCancellableCoroutine { continuation ->
|
|
99
|
+
val params = ConnectionParams.Builder(clientId)
|
|
100
|
+
.setRedirectUri(redirectUri)
|
|
101
|
+
.showAuthView(false)
|
|
102
|
+
.build()
|
|
103
|
+
|
|
104
|
+
SpotifyAppRemote.connect(context, params, object : Connector.ConnectionListener {
|
|
105
|
+
override fun onConnected(remote: SpotifyAppRemote) {
|
|
106
|
+
appRemote = remote
|
|
107
|
+
transitionState("connected")
|
|
108
|
+
subscribeToPlayerState(remote)
|
|
109
|
+
subscribeToCapabilities(remote)
|
|
110
|
+
continuation.resume(Unit)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
override fun onFailure(throwable: Throwable) {
|
|
114
|
+
transitionState("disconnected")
|
|
115
|
+
val code = "CONNECTION_FAILED"
|
|
116
|
+
val message = throwable.message ?: "Unknown connection failure"
|
|
117
|
+
onConnectionError?.invoke(code, message)
|
|
118
|
+
continuation.resumeWithException(
|
|
119
|
+
AppRemoteConnectionFailedException(message, throwable)
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
continuation.invokeOnCancellation {
|
|
125
|
+
appRemote?.let { SpotifyAppRemote.disconnect(it) }
|
|
126
|
+
appRemote = null
|
|
127
|
+
transitionState("disconnected")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
connectMutex.unlock()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Disconnects from the Spotify app. Safe to call when already disconnected.
|
|
137
|
+
*/
|
|
138
|
+
fun disconnect() {
|
|
139
|
+
cancelPlayerStateSubscription()
|
|
140
|
+
cancelCapabilitiesSubscription()
|
|
141
|
+
val remote = appRemote ?: return
|
|
142
|
+
appRemote = null
|
|
143
|
+
SpotifyAppRemote.disconnect(remote)
|
|
144
|
+
transitionState("disconnected")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// MARK: — Player subscription
|
|
148
|
+
|
|
149
|
+
private fun subscribeToPlayerState(remote: SpotifyAppRemote) {
|
|
150
|
+
val subscription = remote.playerApi
|
|
151
|
+
.subscribeToPlayerState()
|
|
152
|
+
.setEventCallback { playerState ->
|
|
153
|
+
onPlayerStateChange?.invoke(playerStateToMap(playerState, this))
|
|
154
|
+
}
|
|
155
|
+
subscription.setErrorCallback { /* subscription errors are non-fatal; connection errors handled separately */ }
|
|
156
|
+
playerStateSubscription = subscription
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fun cancelPlayerStateSubscription() {
|
|
160
|
+
playerStateSubscription?.cancel()
|
|
161
|
+
playerStateSubscription = null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// MARK: — User capabilities subscription
|
|
165
|
+
|
|
166
|
+
private fun subscribeToCapabilities(remote: SpotifyAppRemote) {
|
|
167
|
+
val subscription = remote.userApi
|
|
168
|
+
.subscribeToCapabilities()
|
|
169
|
+
.setEventCallback { capabilities ->
|
|
170
|
+
onCapabilitiesChange?.invoke(capabilitiesToMap(capabilities))
|
|
171
|
+
}
|
|
172
|
+
subscription.setErrorCallback { /* subscription errors are non-fatal */ }
|
|
173
|
+
capabilitiesSubscription = subscription
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private fun cancelCapabilitiesSubscription() {
|
|
177
|
+
capabilitiesSubscription?.cancel()
|
|
178
|
+
capabilitiesSubscription = null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// MARK: — Player transport
|
|
182
|
+
|
|
183
|
+
suspend fun playerPlay(uri: String) {
|
|
184
|
+
requireConnected("Player.play").playerApi.play(uri).awaitVoid("Player.play")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
suspend fun playerPause() {
|
|
188
|
+
requireConnected("Player.pause").playerApi.pause().awaitVoid("Player.pause")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
suspend fun playerResume() {
|
|
192
|
+
requireConnected("Player.resume").playerApi.resume().awaitVoid("Player.resume")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
suspend fun playerSkipNext() {
|
|
196
|
+
requireConnected("Player.skipNext").playerApi.skipNext().awaitVoid("Player.skipNext")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
suspend fun playerSkipPrevious() {
|
|
200
|
+
requireConnected("Player.skipPrevious").playerApi.skipPrevious().awaitVoid("Player.skipPrevious")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
suspend fun playerSeekTo(positionMs: Long) {
|
|
204
|
+
requireConnected("Player.seekTo").playerApi.seekTo(positionMs).awaitVoid("Player.seekTo")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
suspend fun playerSetShuffle(enabled: Boolean) {
|
|
208
|
+
requireConnected("Player.setShuffle").playerApi.setShuffle(enabled).awaitVoid("Player.setShuffle")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
suspend fun playerSetRepeatMode(mode: Int) {
|
|
212
|
+
val repeatMode = when (mode) {
|
|
213
|
+
0 -> Repeat.OFF
|
|
214
|
+
1 -> Repeat.ONE
|
|
215
|
+
2 -> Repeat.ALL
|
|
216
|
+
else -> throw PlayerInvalidParameterException(
|
|
217
|
+
"Player.setRepeatMode: invalid mode $mode — must be 0 (off), 1 (track), or 2 (context)"
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
requireConnected("Player.setRepeatMode").playerApi.setRepeat(repeatMode).awaitVoid("Player.setRepeatMode")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
suspend fun playerSetPodcastPlaybackSpeed(value: Float) {
|
|
224
|
+
val speed = PlaybackSpeed.PodcastPlaybackSpeed.values().firstOrNull {
|
|
225
|
+
// Accept either decimal multipliers (0.5, 1.2, ...) or integer percentages (50, 120, ...).
|
|
226
|
+
abs((it.value / 100f) - value) < 0.01f || abs(it.value.toFloat() - value) < 0.01f
|
|
227
|
+
}
|
|
228
|
+
?: throw PlayerInvalidParameterException(
|
|
229
|
+
"Player.setPodcastPlaybackSpeed: unsupported speed $value. Available: ${
|
|
230
|
+
PlaybackSpeed.PodcastPlaybackSpeed.values().joinToString { (it.value / 100f).toString() }
|
|
231
|
+
}"
|
|
232
|
+
)
|
|
233
|
+
requireConnected("Player.setPodcastPlaybackSpeed").playerApi
|
|
234
|
+
.setPodcastPlaybackSpeed(speed).awaitVoid("Player.setPodcastPlaybackSpeed")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
suspend fun playerQueue(uri: String) {
|
|
238
|
+
requireConnected("Player.queue").playerApi.queue(uri).awaitVoid("Player.queue")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
suspend fun playerGetPlayerState(): Map<String, Any?> {
|
|
242
|
+
val state = requireConnected("Player.getPlayerState").playerApi
|
|
243
|
+
.playerState.awaitResult<PlayerState>("Player.getPlayerState")
|
|
244
|
+
return playerStateToMap(state, this)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
suspend fun playerGetCrossfadeState(): Map<String, Any?> {
|
|
248
|
+
val state = requireConnected("Player.getCrossfadeState").playerApi
|
|
249
|
+
.crossfadeState.awaitResult<CrossfadeState>("Player.getCrossfadeState")
|
|
250
|
+
return mapOf("isEnabled" to state.isEnabled, "duration" to state.duration)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// MARK: — User operations
|
|
254
|
+
|
|
255
|
+
suspend fun userGetCapabilities(): Map<String, Any?> {
|
|
256
|
+
val capabilities = requireConnected("User.getCapabilities").userApi
|
|
257
|
+
.capabilities.awaitUserResult("User.getCapabilities")
|
|
258
|
+
return capabilitiesToMap(capabilities)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
suspend fun userGetLibraryState(uri: String): Map<String, Any?> {
|
|
262
|
+
val state = requireConnected("User.getLibraryState").userApi
|
|
263
|
+
.getLibraryState(uri).awaitResult<LibraryState>("User.getLibraryState", uri)
|
|
264
|
+
return libraryStateToMap(state)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
suspend fun userAddToLibrary(uri: String): Map<String, Any?> {
|
|
268
|
+
val remote = requireConnected("User.addToLibrary")
|
|
269
|
+
remote.userApi.addToLibrary(uri).awaitVoid("User.addToLibrary")
|
|
270
|
+
val state = remote.userApi.getLibraryState(uri).awaitResult<LibraryState>("User.addToLibrary", uri)
|
|
271
|
+
return libraryStateToMap(state)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
suspend fun userRemoveFromLibrary(uri: String): Map<String, Any?> {
|
|
275
|
+
val remote = requireConnected("User.removeFromLibrary")
|
|
276
|
+
remote.userApi.removeFromLibrary(uri).awaitVoid("User.removeFromLibrary")
|
|
277
|
+
val state = remote.userApi.getLibraryState(uri).awaitResult<LibraryState>("User.removeFromLibrary", uri)
|
|
278
|
+
return libraryStateToMap(state)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// MARK: — Content operations
|
|
282
|
+
|
|
283
|
+
suspend fun contentGetRecommendedContentItems(type: String): List<Map<String, Any?>> {
|
|
284
|
+
val remote = appRemote?.takeIf { it.isConnected } ?: throw ContentNotConnectedException("Content.getRecommendedContentItems")
|
|
285
|
+
val mappedType = when (type) {
|
|
286
|
+
"navigation" -> ContentApi.ContentType.NAVIGATION
|
|
287
|
+
"fitness" -> ContentApi.ContentType.FITNESS
|
|
288
|
+
"gaming" -> ContentApi.ContentType.DEFAULT
|
|
289
|
+
else -> ContentApi.ContentType.DEFAULT
|
|
290
|
+
}
|
|
291
|
+
val listItems = remote.contentApi
|
|
292
|
+
.getRecommendedContentItems(mappedType).awaitContentResult("Content.getRecommendedContentItems")
|
|
293
|
+
return listItems.items.map(::contentItemToMap)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
suspend fun contentGetChildren(item: Map<String, Any?>): List<Map<String, Any?>> {
|
|
297
|
+
val remote = appRemote?.takeIf { it.isConnected } ?: throw ContentNotConnectedException("Content.getChildren")
|
|
298
|
+
val listItem = mapToListItem(item)
|
|
299
|
+
val listItems = remote.contentApi
|
|
300
|
+
.getChildrenOfItem(listItem, 200, 0).awaitContentResult("Content.getChildren")
|
|
301
|
+
return listItems.items.map(::contentItemToMap)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// MARK: — Images operations
|
|
305
|
+
|
|
306
|
+
suspend fun imagesLoad(imageIdentifier: String, size: String, cacheDir: File): Map<String, Any?> {
|
|
307
|
+
if (imageIdentifier.isBlank()) {
|
|
308
|
+
throw ImagesInvalidURIException("Images.load: imageIdentifier must be non-empty")
|
|
309
|
+
}
|
|
310
|
+
val remote = appRemote?.takeIf { it.isConnected } ?: throw ImagesNotConnectedException("Images.load")
|
|
311
|
+
val imageUri = ImageUri(imageIdentifier)
|
|
312
|
+
val dimension = when (size) {
|
|
313
|
+
"small" -> Image.Dimension.SMALL
|
|
314
|
+
"medium" -> Image.Dimension.MEDIUM
|
|
315
|
+
else -> Image.Dimension.LARGE
|
|
316
|
+
}
|
|
317
|
+
val bitmap = remote.imagesApi
|
|
318
|
+
.getImage(imageUri, dimension).awaitImageResult("Images.load")
|
|
319
|
+
return mapOf("uri" to writeBitmapToTempFile(bitmap, cacheDir).toURI().toString())
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// MARK: — Internal helpers
|
|
323
|
+
|
|
324
|
+
private fun requireConnected(callsite: String): SpotifyAppRemote {
|
|
325
|
+
return appRemote?.takeIf { it.isConnected }
|
|
326
|
+
?: throw PlayerNotConnectedException(callsite)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private fun transitionState(state: String) {
|
|
330
|
+
connectionState = state
|
|
331
|
+
onConnectionStateChange?.invoke(state)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Awaits a [CallResult] that returns no meaningful value, normalising errors. */
|
|
335
|
+
private suspend fun <T> CallResult<T>.awaitVoid(callsite: String) {
|
|
336
|
+
suspendCancellableCoroutine { continuation ->
|
|
337
|
+
setResultCallback { continuation.resume(Unit) }
|
|
338
|
+
setErrorCallback { throwable ->
|
|
339
|
+
continuation.resumeWithException(normalizePlayerError(throwable, callsite))
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Awaits a [CallResult] and returns the result value, normalising errors. */
|
|
345
|
+
@Suppress("UNCHECKED_CAST")
|
|
346
|
+
private suspend fun <T> CallResult<T>.awaitResult(callsite: String): T {
|
|
347
|
+
return suspendCancellableCoroutine { continuation ->
|
|
348
|
+
setResultCallback { result -> continuation.resume(result) }
|
|
349
|
+
setErrorCallback { throwable ->
|
|
350
|
+
continuation.resumeWithException(normalizePlayerError(throwable, callsite))
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@Suppress("UNCHECKED_CAST")
|
|
356
|
+
private suspend fun <T> CallResult<T>.awaitResult(callsite: String, uri: String): T {
|
|
357
|
+
return suspendCancellableCoroutine { continuation ->
|
|
358
|
+
setResultCallback { result -> continuation.resume(result) }
|
|
359
|
+
setErrorCallback { throwable ->
|
|
360
|
+
continuation.resumeWithException(normalizeUserError(throwable, callsite, uri))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private suspend fun CallResult<Capabilities>.awaitUserResult(callsite: String): Capabilities {
|
|
366
|
+
return suspendCancellableCoroutine { continuation ->
|
|
367
|
+
setResultCallback { result -> continuation.resume(result) }
|
|
368
|
+
setErrorCallback { throwable ->
|
|
369
|
+
continuation.resumeWithException(normalizeUserError(throwable, callsite, ""))
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private suspend fun CallResult<ListItems>.awaitContentResult(callsite: String): ListItems {
|
|
375
|
+
return suspendCancellableCoroutine { continuation ->
|
|
376
|
+
setResultCallback { result -> continuation.resume(result) }
|
|
377
|
+
setErrorCallback { throwable ->
|
|
378
|
+
continuation.resumeWithException(normalizeContentError(throwable, callsite))
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private suspend fun CallResult<Bitmap>.awaitImageResult(callsite: String): Bitmap {
|
|
384
|
+
return suspendCancellableCoroutine { continuation ->
|
|
385
|
+
setResultCallback { result -> continuation.resume(result) }
|
|
386
|
+
setErrorCallback { throwable ->
|
|
387
|
+
continuation.resumeWithException(normalizeImagesError(throwable, callsite))
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private fun normalizePlayerError(throwable: Throwable, callsite: String): CodedException {
|
|
393
|
+
val msg = throwable.message ?: "Unknown error"
|
|
394
|
+
return when {
|
|
395
|
+
msg.contains("premium", ignoreCase = true) -> PlayerPremiumRequiredException(callsite)
|
|
396
|
+
msg.contains("disconnected", ignoreCase = true) ||
|
|
397
|
+
msg.contains("not connected", ignoreCase = true) ->
|
|
398
|
+
PlayerConnectionLostException("$callsite: $msg", throwable)
|
|
399
|
+
msg.contains("not allowed", ignoreCase = true) ||
|
|
400
|
+
msg.contains("restriction", ignoreCase = true) ->
|
|
401
|
+
PlayerOperationNotAllowedException("$callsite: $msg", throwable)
|
|
402
|
+
else -> PlayerUnknownException("$callsite: $msg", throwable)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private fun normalizeUserError(throwable: Throwable, callsite: String, uri: String): CodedException {
|
|
407
|
+
val msg = throwable.message ?: "Unknown error"
|
|
408
|
+
return when {
|
|
409
|
+
msg.contains("disconnected", ignoreCase = true) ||
|
|
410
|
+
msg.contains("not connected", ignoreCase = true) ->
|
|
411
|
+
UserConnectionLostException("$callsite: $msg", throwable)
|
|
412
|
+
msg.contains("uri", ignoreCase = true) ||
|
|
413
|
+
msg.contains("invalid", ignoreCase = true) ->
|
|
414
|
+
UserInvalidURIException(uri, throwable)
|
|
415
|
+
msg.contains("not allowed", ignoreCase = true) ||
|
|
416
|
+
msg.contains("restriction", ignoreCase = true) ->
|
|
417
|
+
UserOperationNotAllowedException("$callsite: $msg", throwable)
|
|
418
|
+
else -> UserUnknownException("$callsite: $msg", throwable)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private fun normalizeContentError(throwable: Throwable, callsite: String): CodedException {
|
|
423
|
+
val msg = throwable.message ?: "Unknown error"
|
|
424
|
+
return when {
|
|
425
|
+
msg.contains("disconnected", ignoreCase = true) ||
|
|
426
|
+
msg.contains("not connected", ignoreCase = true) ->
|
|
427
|
+
ContentConnectionLostException("$callsite: $msg", throwable)
|
|
428
|
+
msg.contains("not supported", ignoreCase = true) ||
|
|
429
|
+
msg.contains("unsupported", ignoreCase = true) ->
|
|
430
|
+
ContentApiUnavailableException("$callsite: content API unavailable on this Spotify app version", throwable)
|
|
431
|
+
else -> ContentUnknownException("$callsite: $msg", throwable)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private fun normalizeImagesError(throwable: Throwable, callsite: String): CodedException {
|
|
436
|
+
val msg = throwable.message ?: "Unknown error"
|
|
437
|
+
return when {
|
|
438
|
+
msg.contains("disconnected", ignoreCase = true) ||
|
|
439
|
+
msg.contains("not connected", ignoreCase = true) ->
|
|
440
|
+
ImagesNotConnectedException(callsite)
|
|
441
|
+
msg.contains("invalid", ignoreCase = true) ->
|
|
442
|
+
ImagesInvalidURIException("$callsite: invalid image identifier", throwable)
|
|
443
|
+
msg.contains("load", ignoreCase = true) || msg.contains("image", ignoreCase = true) ->
|
|
444
|
+
ImagesLoadFailedException("$callsite: $msg", throwable)
|
|
445
|
+
else -> ImagesUnknownException("$callsite: $msg", throwable)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private fun writeBitmapToTempFile(bitmap: Bitmap, cacheDir: File): File {
|
|
450
|
+
val out = File(cacheDir, "expo-spotify-image-${System.currentTimeMillis()}-${(0..9999).random()}.png")
|
|
451
|
+
FileOutputStream(out).use { stream ->
|
|
452
|
+
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) {
|
|
453
|
+
throw ImagesLoadFailedException("Images.load: failed to compress bitmap")
|
|
454
|
+
}
|
|
455
|
+
stream.flush()
|
|
456
|
+
}
|
|
457
|
+
return out
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
companion object {
|
|
461
|
+
private fun playerStateToMap(
|
|
462
|
+
state: PlayerState,
|
|
463
|
+
coordinator: SpotifyAppRemoteCoordinator,
|
|
464
|
+
): Map<String, Any?> {
|
|
465
|
+
val track = state.track
|
|
466
|
+
val trackUri = track?.uri ?: ""
|
|
467
|
+
val rawTrackName = track?.name?.trim().orEmpty()
|
|
468
|
+
val sameTrackAsPrevious = coordinator.lastTrackUri == trackUri && trackUri.isNotBlank()
|
|
469
|
+
val resolvedTrackName = when {
|
|
470
|
+
rawTrackName.isNotBlank() -> {
|
|
471
|
+
coordinator.lastTrackUri = trackUri
|
|
472
|
+
coordinator.lastNonEmptyTrackName = rawTrackName
|
|
473
|
+
rawTrackName
|
|
474
|
+
}
|
|
475
|
+
sameTrackAsPrevious && !coordinator.lastNonEmptyTrackName.isNullOrBlank() ->
|
|
476
|
+
coordinator.lastNonEmptyTrackName!!
|
|
477
|
+
else -> rawTrackName
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
val options = state.playbackOptions
|
|
481
|
+
val restrictions = state.playbackRestrictions
|
|
482
|
+
return mapOf(
|
|
483
|
+
"track" to mapOf(
|
|
484
|
+
"uri" to trackUri,
|
|
485
|
+
"name" to resolvedTrackName,
|
|
486
|
+
"imageIdentifier" to (track?.imageUri?.raw ?: ""),
|
|
487
|
+
"duration" to (track?.duration ?: 0L),
|
|
488
|
+
"artist" to mapOf(
|
|
489
|
+
"name" to (track?.artist?.name ?: ""),
|
|
490
|
+
"uri" to (track?.artist?.uri ?: ""),
|
|
491
|
+
),
|
|
492
|
+
"album" to mapOf(
|
|
493
|
+
"name" to (track?.album?.name ?: ""),
|
|
494
|
+
"uri" to (track?.album?.uri ?: ""),
|
|
495
|
+
),
|
|
496
|
+
"isEpisode" to (track?.isEpisode ?: false),
|
|
497
|
+
"isPodcast" to (track?.isPodcast ?: false),
|
|
498
|
+
"isSaved" to false,
|
|
499
|
+
"isAdvertisement" to false,
|
|
500
|
+
),
|
|
501
|
+
"playbackPosition" to state.playbackPosition,
|
|
502
|
+
"playbackSpeed" to state.playbackSpeed,
|
|
503
|
+
"isPaused" to state.isPaused,
|
|
504
|
+
"playbackOptions" to mapOf(
|
|
505
|
+
"isShuffling" to (options?.isShuffling ?: false),
|
|
506
|
+
"repeatMode" to (options?.repeatMode ?: Repeat.OFF),
|
|
507
|
+
),
|
|
508
|
+
"playbackRestrictions" to mapOf(
|
|
509
|
+
"canSkipNext" to (restrictions?.canSkipNext ?: false),
|
|
510
|
+
"canSkipPrevious" to (restrictions?.canSkipPrev ?: false),
|
|
511
|
+
"canRepeatTrack" to (restrictions?.canRepeatTrack ?: false),
|
|
512
|
+
"canRepeatContext" to (restrictions?.canRepeatContext ?: false),
|
|
513
|
+
"canToggleShuffle" to (restrictions?.canToggleShuffle ?: false),
|
|
514
|
+
"canSeek" to (restrictions?.canSeek ?: false),
|
|
515
|
+
),
|
|
516
|
+
"contextTitle" to "",
|
|
517
|
+
"contextUri" to "",
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private fun capabilitiesToMap(capabilities: Capabilities): Map<String, Any?> {
|
|
522
|
+
return mapOf("canPlayOnDemand" to capabilities.canPlayOnDemand)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private fun libraryStateToMap(state: LibraryState): Map<String, Any?> {
|
|
526
|
+
return mapOf("uri" to state.uri, "isAdded" to state.isAdded, "canAdd" to state.canAdd)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private fun contentItemToMap(item: ListItem): Map<String, Any?> {
|
|
530
|
+
return mapOf(
|
|
531
|
+
"title" to item.title,
|
|
532
|
+
"subtitle" to item.subtitle,
|
|
533
|
+
"contentDescription" to null,
|
|
534
|
+
"identifier" to item.id,
|
|
535
|
+
"uri" to item.uri,
|
|
536
|
+
"imageIdentifier" to item.imageUri?.raw,
|
|
537
|
+
"isAvailableOffline" to false,
|
|
538
|
+
"isPlayable" to item.playable,
|
|
539
|
+
"isContainer" to item.hasChildren,
|
|
540
|
+
"isPinned" to false,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private fun mapToListItem(item: Map<String, Any?>): ListItem {
|
|
545
|
+
val id = (item["identifier"] as? String).orEmpty()
|
|
546
|
+
val uri = (item["uri"] as? String).orEmpty()
|
|
547
|
+
val title = (item["title"] as? String).orEmpty()
|
|
548
|
+
val subtitle = (item["subtitle"] as? String).orEmpty()
|
|
549
|
+
val imageIdentifier = item["imageIdentifier"] as? String
|
|
550
|
+
val playable = item["isPlayable"] as? Boolean ?: false
|
|
551
|
+
val isContainer = item["isContainer"] as? Boolean ?: false
|
|
552
|
+
val imageUri = imageIdentifier?.takeIf { it.isNotBlank() }?.let(::ImageUri)
|
|
553
|
+
return ListItem(id, uri, imageUri, title, subtitle, playable, isContainer)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
@@ -32,3 +32,115 @@ class SpotifyAuthErrorException(error: String) :
|
|
|
32
32
|
|
|
33
33
|
class UnknownSpotifyException(message: String, cause: Throwable? = null) :
|
|
34
34
|
CodedException("UNKNOWN", message, cause)
|
|
35
|
+
|
|
36
|
+
// ── App Remote errors ────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
class AppRemoteConnectionFailedException(message: String, cause: Throwable? = null) :
|
|
39
|
+
CodedException("CONNECTION_FAILED", message, cause)
|
|
40
|
+
|
|
41
|
+
class AppRemoteConnectionLostException(message: String, cause: Throwable? = null) :
|
|
42
|
+
CodedException("CONNECTION_LOST", message, cause)
|
|
43
|
+
|
|
44
|
+
class AppRemoteNotConnectedException(callsite: String = "") :
|
|
45
|
+
CodedException(
|
|
46
|
+
"NOT_CONNECTED",
|
|
47
|
+
if (callsite.isNotBlank()) "Not connected to the Spotify app — call AppRemote.connect() before $callsite"
|
|
48
|
+
else "Not connected to the Spotify app. Call AppRemote.connect() first.",
|
|
49
|
+
null,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
class AppRemoteUnknownException(message: String, cause: Throwable? = null) :
|
|
53
|
+
CodedException("UNKNOWN", message, cause)
|
|
54
|
+
|
|
55
|
+
// ── Player errors ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
class PlayerNotConnectedException(callsite: String = "") :
|
|
58
|
+
CodedException(
|
|
59
|
+
"NOT_CONNECTED",
|
|
60
|
+
if (callsite.isNotBlank()) "Not connected to the Spotify app — call AppRemote.connect() before $callsite"
|
|
61
|
+
else "Not connected to the Spotify app. Call AppRemote.connect() first.",
|
|
62
|
+
null,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
class PlayerConnectionLostException(message: String, cause: Throwable? = null) :
|
|
66
|
+
CodedException("CONNECTION_LOST", message, cause)
|
|
67
|
+
|
|
68
|
+
class PlayerPremiumRequiredException(callsite: String = "") :
|
|
69
|
+
CodedException(
|
|
70
|
+
"PREMIUM_REQUIRED",
|
|
71
|
+
if (callsite.isNotBlank()) "$callsite: Spotify Premium is required for on-demand playback"
|
|
72
|
+
else "Spotify Premium is required for on-demand playback",
|
|
73
|
+
null,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
class PlayerInvalidURIException(uri: String, cause: Throwable? = null) :
|
|
77
|
+
CodedException("INVALID_URI", "Invalid Spotify URI: $uri", cause)
|
|
78
|
+
|
|
79
|
+
class PlayerInvalidParameterException(message: String, cause: Throwable? = null) :
|
|
80
|
+
CodedException("INVALID_PARAMETER", message, cause)
|
|
81
|
+
|
|
82
|
+
class PlayerOperationNotAllowedException(message: String, cause: Throwable? = null) :
|
|
83
|
+
CodedException("OPERATION_NOT_ALLOWED", message, cause)
|
|
84
|
+
|
|
85
|
+
class PlayerUnknownException(message: String, cause: Throwable? = null) :
|
|
86
|
+
CodedException("UNKNOWN", message, cause)
|
|
87
|
+
|
|
88
|
+
// ── User errors ───────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
class UserNotConnectedException(callsite: String = "") :
|
|
91
|
+
CodedException(
|
|
92
|
+
"NOT_CONNECTED",
|
|
93
|
+
if (callsite.isNotBlank()) "Not connected to the Spotify app — call AppRemote.connect() before $callsite"
|
|
94
|
+
else "Not connected to the Spotify app. Call AppRemote.connect() first.",
|
|
95
|
+
null,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
class UserConnectionLostException(message: String, cause: Throwable? = null) :
|
|
99
|
+
CodedException("CONNECTION_LOST", message, cause)
|
|
100
|
+
|
|
101
|
+
class UserInvalidURIException(uri: String, cause: Throwable? = null) :
|
|
102
|
+
CodedException("INVALID_URI", "Invalid Spotify URI: $uri", cause)
|
|
103
|
+
|
|
104
|
+
class UserOperationNotAllowedException(message: String, cause: Throwable? = null) :
|
|
105
|
+
CodedException("OPERATION_NOT_ALLOWED", message, cause)
|
|
106
|
+
|
|
107
|
+
class UserUnknownException(message: String, cause: Throwable? = null) :
|
|
108
|
+
CodedException("UNKNOWN", message, cause)
|
|
109
|
+
|
|
110
|
+
// ── Content errors ────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
class ContentNotConnectedException(callsite: String = "") :
|
|
113
|
+
CodedException(
|
|
114
|
+
"NOT_CONNECTED",
|
|
115
|
+
if (callsite.isNotBlank()) "Not connected to the Spotify app — call AppRemote.connect() before $callsite"
|
|
116
|
+
else "Not connected to the Spotify app. Call AppRemote.connect() first.",
|
|
117
|
+
null,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
class ContentConnectionLostException(message: String, cause: Throwable? = null) :
|
|
121
|
+
CodedException("CONNECTION_LOST", message, cause)
|
|
122
|
+
|
|
123
|
+
class ContentApiUnavailableException(message: String, cause: Throwable? = null) :
|
|
124
|
+
CodedException("CONTENT_API_UNAVAILABLE", message, cause)
|
|
125
|
+
|
|
126
|
+
class ContentUnknownException(message: String, cause: Throwable? = null) :
|
|
127
|
+
CodedException("UNKNOWN", message, cause)
|
|
128
|
+
|
|
129
|
+
// ── Images errors ─────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
class ImagesNotConnectedException(callsite: String = "") :
|
|
132
|
+
CodedException(
|
|
133
|
+
"NOT_CONNECTED",
|
|
134
|
+
if (callsite.isNotBlank()) "Not connected to the Spotify app — call AppRemote.connect() before $callsite"
|
|
135
|
+
else "Not connected to the Spotify app. Call AppRemote.connect() first.",
|
|
136
|
+
null,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
class ImagesInvalidURIException(message: String, cause: Throwable? = null) :
|
|
140
|
+
CodedException("INVALID_URI", message, cause)
|
|
141
|
+
|
|
142
|
+
class ImagesLoadFailedException(message: String, cause: Throwable? = null) :
|
|
143
|
+
CodedException("IMAGE_LOAD_FAILED", message, cause)
|
|
144
|
+
|
|
145
|
+
class ImagesUnknownException(message: String, cause: Throwable? = null) :
|
|
146
|
+
CodedException("UNKNOWN", message, cause)
|