@wwdrew/expo-spotify-sdk 0.8.0 → 2.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.
Files changed (87) hide show
  1. package/README.md +404 -140
  2. package/android/build.gradle +12 -2
  3. package/android/consumer-rules.pro +5 -0
  4. package/android/libs/SETUP.md +29 -0
  5. package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +146 -9
  6. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +556 -0
  7. package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +112 -0
  8. package/app.plugin.js +1 -1
  9. package/build/ExpoSpotifySDKModule.web.d.ts +23 -0
  10. package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
  11. package/build/ExpoSpotifySDKModule.web.js +74 -5
  12. package/build/ExpoSpotifySDKModule.web.js.map +1 -1
  13. package/build/app-remote/error.d.ts +8 -0
  14. package/build/app-remote/error.d.ts.map +1 -0
  15. package/build/app-remote/error.js +10 -0
  16. package/build/app-remote/error.js.map +1 -0
  17. package/build/app-remote/index.d.ts +79 -0
  18. package/build/app-remote/index.d.ts.map +1 -0
  19. package/build/app-remote/index.js +110 -0
  20. package/build/app-remote/index.js.map +1 -0
  21. package/build/auth/error.d.ts +8 -0
  22. package/build/auth/error.d.ts.map +1 -0
  23. package/build/auth/error.js +10 -0
  24. package/build/auth/error.js.map +1 -0
  25. package/build/auth/index.d.ts +124 -0
  26. package/build/auth/index.d.ts.map +1 -0
  27. package/build/auth/index.js +162 -0
  28. package/build/auth/index.js.map +1 -0
  29. package/build/content/error.d.ts +8 -0
  30. package/build/content/error.d.ts.map +1 -0
  31. package/build/content/error.js +10 -0
  32. package/build/content/error.js.map +1 -0
  33. package/build/content/index.d.ts +38 -0
  34. package/build/content/index.d.ts.map +1 -0
  35. package/build/content/index.js +56 -0
  36. package/build/content/index.js.map +1 -0
  37. package/build/error.d.ts +24 -0
  38. package/build/error.d.ts.map +1 -0
  39. package/build/error.js +25 -0
  40. package/build/error.js.map +1 -0
  41. package/build/hooks/index.d.ts +90 -0
  42. package/build/hooks/index.d.ts.map +1 -0
  43. package/build/hooks/index.js +326 -0
  44. package/build/hooks/index.js.map +1 -0
  45. package/build/images/error.d.ts +8 -0
  46. package/build/images/error.d.ts.map +1 -0
  47. package/build/images/error.js +10 -0
  48. package/build/images/error.js.map +1 -0
  49. package/build/images/index.d.ts +36 -0
  50. package/build/images/index.d.ts.map +1 -0
  51. package/build/images/index.js +59 -0
  52. package/build/images/index.js.map +1 -0
  53. package/build/index.d.ts +44 -56
  54. package/build/index.d.ts.map +1 -1
  55. package/build/index.js +49 -141
  56. package/build/index.js.map +1 -1
  57. package/build/player/error.d.ts +8 -0
  58. package/build/player/error.d.ts.map +1 -0
  59. package/build/player/error.js +10 -0
  60. package/build/player/error.js.map +1 -0
  61. package/build/player/index.d.ts +140 -0
  62. package/build/player/index.d.ts.map +1 -0
  63. package/build/player/index.js +137 -0
  64. package/build/player/index.js.map +1 -0
  65. package/build/uri/index.d.ts +55 -0
  66. package/build/uri/index.d.ts.map +1 -0
  67. package/build/uri/index.js +57 -0
  68. package/build/uri/index.js.map +1 -0
  69. package/build/user/error.d.ts +8 -0
  70. package/build/user/error.d.ts.map +1 -0
  71. package/build/user/error.js +10 -0
  72. package/build/user/error.js.map +1 -0
  73. package/build/user/index.d.ts +44 -0
  74. package/build/user/index.d.ts.map +1 -0
  75. package/build/user/index.js +97 -0
  76. package/build/user/index.js.map +1 -0
  77. package/ios/ExpoSpotifySDK.podspec +1 -1
  78. package/ios/ExpoSpotifySDKModule.swift +227 -5
  79. package/ios/SpotifyAppRemoteCoordinator.swift +939 -0
  80. package/ios/SpotifyAuthCoordinator.swift +53 -13
  81. package/ios/SpotifyTokenRefreshClient.swift +28 -0
  82. package/package.json +29 -16
  83. package/plugin/build/index.d.ts +20 -3
  84. package/plugin/build/index.js +22 -25
  85. package/plugin/build/types.d.ts +17 -1
  86. package/plugin/build/withSpotifySdkConfig.d.ts +4 -0
  87. package/plugin/build/withSpotifySdkConfig.js +27 -0
@@ -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)
package/app.plugin.js CHANGED
@@ -1 +1 @@
1
- module.exports = require("./plugin/build");
1
+ module.exports = require("./plugin/build/withSpotifySdkConfig");