@wwdrew/expo-spotify-sdk 0.8.0 → 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.
Files changed (80) hide show
  1. package/README.md +378 -138
  2. package/android/build.gradle +10 -0
  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/build/ExpoSpotifySDKModule.web.d.ts +23 -0
  9. package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
  10. package/build/ExpoSpotifySDKModule.web.js +74 -5
  11. package/build/ExpoSpotifySDKModule.web.js.map +1 -1
  12. package/build/app-remote/error.d.ts +8 -0
  13. package/build/app-remote/error.d.ts.map +1 -0
  14. package/build/app-remote/error.js +10 -0
  15. package/build/app-remote/error.js.map +1 -0
  16. package/build/app-remote/index.d.ts +79 -0
  17. package/build/app-remote/index.d.ts.map +1 -0
  18. package/build/app-remote/index.js +110 -0
  19. package/build/app-remote/index.js.map +1 -0
  20. package/build/auth/error.d.ts +8 -0
  21. package/build/auth/error.d.ts.map +1 -0
  22. package/build/auth/error.js +10 -0
  23. package/build/auth/error.js.map +1 -0
  24. package/build/auth/index.d.ts +124 -0
  25. package/build/auth/index.d.ts.map +1 -0
  26. package/build/auth/index.js +162 -0
  27. package/build/auth/index.js.map +1 -0
  28. package/build/content/error.d.ts +8 -0
  29. package/build/content/error.d.ts.map +1 -0
  30. package/build/content/error.js +10 -0
  31. package/build/content/error.js.map +1 -0
  32. package/build/content/index.d.ts +38 -0
  33. package/build/content/index.d.ts.map +1 -0
  34. package/build/content/index.js +56 -0
  35. package/build/content/index.js.map +1 -0
  36. package/build/error.d.ts +24 -0
  37. package/build/error.d.ts.map +1 -0
  38. package/build/error.js +25 -0
  39. package/build/error.js.map +1 -0
  40. package/build/hooks/index.d.ts +90 -0
  41. package/build/hooks/index.d.ts.map +1 -0
  42. package/build/hooks/index.js +326 -0
  43. package/build/hooks/index.js.map +1 -0
  44. package/build/images/error.d.ts +8 -0
  45. package/build/images/error.d.ts.map +1 -0
  46. package/build/images/error.js +10 -0
  47. package/build/images/error.js.map +1 -0
  48. package/build/images/index.d.ts +36 -0
  49. package/build/images/index.d.ts.map +1 -0
  50. package/build/images/index.js +59 -0
  51. package/build/images/index.js.map +1 -0
  52. package/build/index.d.ts +44 -56
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +49 -141
  55. package/build/index.js.map +1 -1
  56. package/build/player/error.d.ts +8 -0
  57. package/build/player/error.d.ts.map +1 -0
  58. package/build/player/error.js +10 -0
  59. package/build/player/error.js.map +1 -0
  60. package/build/player/index.d.ts +140 -0
  61. package/build/player/index.d.ts.map +1 -0
  62. package/build/player/index.js +136 -0
  63. package/build/player/index.js.map +1 -0
  64. package/build/uri/index.d.ts +55 -0
  65. package/build/uri/index.d.ts.map +1 -0
  66. package/build/uri/index.js +57 -0
  67. package/build/uri/index.js.map +1 -0
  68. package/build/user/error.d.ts +8 -0
  69. package/build/user/error.d.ts.map +1 -0
  70. package/build/user/error.js +10 -0
  71. package/build/user/error.js.map +1 -0
  72. package/build/user/index.d.ts +44 -0
  73. package/build/user/index.d.ts.map +1 -0
  74. package/build/user/index.js +97 -0
  75. package/build/user/index.js.map +1 -0
  76. package/ios/ExpoSpotifySDKModule.swift +227 -5
  77. package/ios/SpotifyAppRemoteCoordinator.swift +939 -0
  78. package/ios/SpotifyAuthCoordinator.swift +53 -13
  79. package/ios/SpotifyTokenRefreshClient.swift +28 -0
  80. package/package.json +4 -3
@@ -0,0 +1,939 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+ import SpotifyiOS
4
+ import UIKit
5
+
6
+ // MARK: — App Remote error types
7
+
8
+ enum AppRemoteError: Error {
9
+ case connectionFailed(String)
10
+ case connectionLost(String)
11
+ case notConnected(String)
12
+ case unknown(String)
13
+
14
+ var code: String {
15
+ switch self {
16
+ case .connectionFailed: return "CONNECTION_FAILED"
17
+ case .connectionLost: return "CONNECTION_LOST"
18
+ case .notConnected: return "NOT_CONNECTED"
19
+ case .unknown: return "UNKNOWN"
20
+ }
21
+ }
22
+
23
+ var message: String {
24
+ switch self {
25
+ case .connectionFailed(let m): return m
26
+ case .connectionLost(let m): return m
27
+ case .notConnected(let m): return m
28
+ case .unknown(let m): return m
29
+ }
30
+ }
31
+ }
32
+
33
+ /// Bridges an `AppRemoteError` through expo-modules-core's exception system so
34
+ /// JS callers receive a structured `code` and `reason`, not "undefined reason".
35
+ final class AppRemoteException: Exception, @unchecked Sendable {
36
+ private let appRemoteCode: String
37
+ private let appRemoteMessage: String
38
+
39
+ init(_ error: AppRemoteError, file: String = #fileID, line: UInt = #line, function: String = #function) {
40
+ self.appRemoteCode = error.code
41
+ self.appRemoteMessage = error.message
42
+ super.init(file: file, line: line, function: function)
43
+ }
44
+
45
+ override var code: String { appRemoteCode }
46
+ override var reason: String { appRemoteMessage }
47
+ }
48
+
49
+ // MARK: — Player error types
50
+
51
+ enum NativePlayerError: Error {
52
+ case notConnected(String)
53
+ case connectionLost(String)
54
+ case premiumRequired(String)
55
+ case invalidURI(String)
56
+ case invalidParameter(String)
57
+ case operationNotAllowed(String)
58
+ case unknown(String)
59
+
60
+ var code: String {
61
+ switch self {
62
+ case .notConnected: return "NOT_CONNECTED"
63
+ case .connectionLost: return "CONNECTION_LOST"
64
+ case .premiumRequired: return "PREMIUM_REQUIRED"
65
+ case .invalidURI: return "INVALID_URI"
66
+ case .invalidParameter: return "INVALID_PARAMETER"
67
+ case .operationNotAllowed: return "OPERATION_NOT_ALLOWED"
68
+ case .unknown: return "UNKNOWN"
69
+ }
70
+ }
71
+
72
+ var message: String {
73
+ switch self {
74
+ case .notConnected(let m): return m
75
+ case .connectionLost(let m): return m
76
+ case .premiumRequired(let m): return m
77
+ case .invalidURI(let m): return m
78
+ case .invalidParameter(let m): return m
79
+ case .operationNotAllowed(let m): return m
80
+ case .unknown(let m): return m
81
+ }
82
+ }
83
+ }
84
+
85
+ final class PlayerException: Exception, @unchecked Sendable {
86
+ private let playerCode: String
87
+ private let playerMessage: String
88
+
89
+ init(_ error: NativePlayerError, file: String = #fileID, line: UInt = #line, function: String = #function) {
90
+ self.playerCode = error.code
91
+ self.playerMessage = error.message
92
+ super.init(file: file, line: line, function: function)
93
+ }
94
+
95
+ override var code: String { playerCode }
96
+ override var reason: String { playerMessage }
97
+ }
98
+
99
+ // MARK: — User error types
100
+
101
+ enum NativeUserError: Error {
102
+ case notConnected(String)
103
+ case connectionLost(String)
104
+ case invalidURI(String)
105
+ case operationNotAllowed(String)
106
+ case unknown(String)
107
+
108
+ var code: String {
109
+ switch self {
110
+ case .notConnected: return "NOT_CONNECTED"
111
+ case .connectionLost: return "CONNECTION_LOST"
112
+ case .invalidURI: return "INVALID_URI"
113
+ case .operationNotAllowed: return "OPERATION_NOT_ALLOWED"
114
+ case .unknown: return "UNKNOWN"
115
+ }
116
+ }
117
+
118
+ var message: String {
119
+ switch self {
120
+ case .notConnected(let m): return m
121
+ case .connectionLost(let m): return m
122
+ case .invalidURI(let m): return m
123
+ case .operationNotAllowed(let m): return m
124
+ case .unknown(let m): return m
125
+ }
126
+ }
127
+ }
128
+
129
+ final class UserException: Exception, @unchecked Sendable {
130
+ private let userCode: String
131
+ private let userMessage: String
132
+
133
+ init(_ error: NativeUserError, file: String = #fileID, line: UInt = #line, function: String = #function) {
134
+ self.userCode = error.code
135
+ self.userMessage = error.message
136
+ super.init(file: file, line: line, function: function)
137
+ }
138
+
139
+ override var code: String { userCode }
140
+ override var reason: String { userMessage }
141
+ }
142
+
143
+ // MARK: — Content error types
144
+
145
+ enum NativeContentError: Error {
146
+ case notConnected(String)
147
+ case connectionLost(String)
148
+ case contentAPIUnavailable(String)
149
+ case unknown(String)
150
+
151
+ var code: String {
152
+ switch self {
153
+ case .notConnected: return "NOT_CONNECTED"
154
+ case .connectionLost: return "CONNECTION_LOST"
155
+ case .contentAPIUnavailable: return "CONTENT_API_UNAVAILABLE"
156
+ case .unknown: return "UNKNOWN"
157
+ }
158
+ }
159
+
160
+ var message: String {
161
+ switch self {
162
+ case .notConnected(let m): return m
163
+ case .connectionLost(let m): return m
164
+ case .contentAPIUnavailable(let m): return m
165
+ case .unknown(let m): return m
166
+ }
167
+ }
168
+ }
169
+
170
+ final class ContentException: Exception, @unchecked Sendable {
171
+ private let contentCode: String
172
+ private let contentMessage: String
173
+
174
+ init(_ error: NativeContentError, file: String = #fileID, line: UInt = #line, function: String = #function) {
175
+ self.contentCode = error.code
176
+ self.contentMessage = error.message
177
+ super.init(file: file, line: line, function: function)
178
+ }
179
+
180
+ override var code: String { contentCode }
181
+ override var reason: String { contentMessage }
182
+ }
183
+
184
+ // MARK: — Images error types
185
+
186
+ enum NativeImagesError: Error {
187
+ case notConnected(String)
188
+ case invalidURI(String)
189
+ case imageLoadFailed(String)
190
+ case unknown(String)
191
+
192
+ var code: String {
193
+ switch self {
194
+ case .notConnected: return "NOT_CONNECTED"
195
+ case .invalidURI: return "INVALID_URI"
196
+ case .imageLoadFailed: return "IMAGE_LOAD_FAILED"
197
+ case .unknown: return "UNKNOWN"
198
+ }
199
+ }
200
+
201
+ var message: String {
202
+ switch self {
203
+ case .notConnected(let m): return m
204
+ case .invalidURI(let m): return m
205
+ case .imageLoadFailed(let m): return m
206
+ case .unknown(let m): return m
207
+ }
208
+ }
209
+ }
210
+
211
+ final class ImagesException: Exception, @unchecked Sendable {
212
+ private let imagesCode: String
213
+ private let imagesMessage: String
214
+
215
+ init(_ error: NativeImagesError, file: String = #fileID, line: UInt = #line, function: String = #function) {
216
+ self.imagesCode = error.code
217
+ self.imagesMessage = error.message
218
+ super.init(file: file, line: line, function: function)
219
+ }
220
+
221
+ override var code: String { imagesCode }
222
+ override var reason: String { imagesMessage }
223
+ }
224
+
225
+ // MARK: — Coordinator
226
+
227
+ /// Manages the `SPTAppRemote` singleton, the IPC connection lifecycle, and all
228
+ /// player transport operations for the running Spotify app.
229
+ ///
230
+ /// - The `SPTAppRemote` instance is created fresh on each `connect()` call so
231
+ /// the access token (which may change across sessions) is always current.
232
+ /// - `connectionStateString` is `nonisolated(unsafe)` so the module can query
233
+ /// it synchronously from the Expo bridge without an async hop. Writes only
234
+ /// happen from within the actor's isolation, so the value is safe to read for
235
+ /// display purposes (occasional stale reads are acceptable).
236
+ /// - Player state subscription is established automatically on connection and
237
+ /// torn down on disconnection; events are forwarded to `onPlayerStateChange`.
238
+ actor SpotifyAppRemoteCoordinator {
239
+ static let shared: SpotifyAppRemoteCoordinator? = SpotifyAppRemoteCoordinator.create()
240
+
241
+ private let sptConfiguration: SPTConfiguration
242
+ private let connectionBridge: SpotifyAppRemoteDelegateBridge
243
+ private let playerStateBridge: SpotifyPlayerStateDelegateBridge
244
+ private let userCapabilitiesBridge: SpotifyUserCapabilitiesDelegateBridge
245
+ private var appRemote: SPTAppRemote?
246
+ private var connectContinuation: CheckedContinuation<Void, Error>?
247
+
248
+ nonisolated(unsafe) private(set) var connectionStateString: String = "disconnected"
249
+
250
+ var onConnectionStateChange: ((String) -> Void)?
251
+ var onConnectionError: ((String, String) -> Void)?
252
+ var onPlayerStateChange: (([String: Any]) -> Void)?
253
+ var onCapabilitiesChange: (([String: Any]) -> Void)?
254
+
255
+ func setEventHandlers(
256
+ onConnectionStateChange: @escaping (String) -> Void,
257
+ onConnectionError: @escaping (String, String) -> Void,
258
+ onPlayerStateChange: @escaping ([String: Any]) -> Void,
259
+ onCapabilitiesChange: @escaping ([String: Any]) -> Void
260
+ ) {
261
+ self.onConnectionStateChange = onConnectionStateChange
262
+ self.onConnectionError = onConnectionError
263
+ self.onPlayerStateChange = onPlayerStateChange
264
+ self.onCapabilitiesChange = onCapabilitiesChange
265
+ }
266
+
267
+ private init(sptConfiguration: SPTConfiguration) {
268
+ self.sptConfiguration = sptConfiguration
269
+ self.connectionBridge = SpotifyAppRemoteDelegateBridge()
270
+ self.playerStateBridge = SpotifyPlayerStateDelegateBridge()
271
+ self.userCapabilitiesBridge = SpotifyUserCapabilitiesDelegateBridge()
272
+ connectionBridge.coordinator = self
273
+ playerStateBridge.coordinator = self
274
+ userCapabilitiesBridge.coordinator = self
275
+ }
276
+
277
+ private static func create() -> SpotifyAppRemoteCoordinator? {
278
+ guard
279
+ let configuration = ExpoSpotifyConfiguration.fromInfoPlist(),
280
+ let sptConfig = configuration.sptConfiguration
281
+ else {
282
+ NSLog("[ExpoSpotifySDK] AppRemote: missing or invalid `ExpoSpotifySDK` Info.plist entry")
283
+ return nil
284
+ }
285
+ return SpotifyAppRemoteCoordinator(sptConfiguration: sptConfig)
286
+ }
287
+
288
+ // MARK: — Connection lifecycle
289
+
290
+ func connect(accessToken: String) async throws {
291
+ if appRemote?.isConnected == true { return }
292
+ guard connectContinuation == nil else {
293
+ throw AppRemoteError.connectionFailed("A connection attempt is already in progress")
294
+ }
295
+
296
+ transitionState("connecting")
297
+
298
+ let params = SPTAppRemoteConnectionParams(
299
+ accessToken: accessToken,
300
+ defaultImageSize: CGSize.zero,
301
+ imageFormat: .any
302
+ )
303
+ let remote = SPTAppRemote(
304
+ configuration: sptConfiguration,
305
+ connectionParameters: params,
306
+ logLevel: .error
307
+ )
308
+ remote.delegate = connectionBridge
309
+ appRemote = remote
310
+
311
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
312
+ self.connectContinuation = cont
313
+ Task { @MainActor in remote.connect() }
314
+ }
315
+ }
316
+
317
+ func disconnect() {
318
+ if let cont = connectContinuation {
319
+ connectContinuation = nil
320
+ cont.resume(throwing: AppRemoteError.connectionFailed("Disconnected before connection completed"))
321
+ }
322
+ teardownPlayerSubscription()
323
+ teardownCapabilitiesSubscription()
324
+ appRemote?.disconnect()
325
+ appRemote = nil
326
+ transitionState("disconnected")
327
+ }
328
+
329
+ nonisolated func isConnected() -> Bool {
330
+ connectionStateString == "connected"
331
+ }
332
+
333
+ func getConnectionState() -> String {
334
+ connectionStateString
335
+ }
336
+
337
+ // MARK: — Connection delegate callbacks
338
+
339
+ func didConnect() {
340
+ transitionState("connected")
341
+ setupPlayerSubscription()
342
+ setupCapabilitiesSubscription()
343
+ let cont = connectContinuation
344
+ connectContinuation = nil
345
+ cont?.resume()
346
+ }
347
+
348
+ func didFailToConnect(error: Error?) {
349
+ appRemote = nil
350
+ let msg = error.map { describeNSError($0 as NSError) } ?? "Unknown connection failure"
351
+ let remoteError = AppRemoteError.connectionFailed(msg)
352
+ transitionState("disconnected")
353
+ onConnectionError?(remoteError.code, remoteError.message)
354
+ let cont = connectContinuation
355
+ connectContinuation = nil
356
+ cont?.resume(throwing: remoteError)
357
+ }
358
+
359
+ func didDisconnect(error: Error?) {
360
+ appRemote = nil
361
+ transitionState("disconnected")
362
+ if let error = error {
363
+ let msg = describeNSError(error as NSError)
364
+ onConnectionError?(AppRemoteError.connectionLost(msg).code, msg)
365
+ }
366
+ }
367
+
368
+ // MARK: — Player subscription
369
+
370
+ private func setupPlayerSubscription() {
371
+ guard let playerAPI = appRemote?.playerAPI else { return }
372
+ playerAPI.delegate = playerStateBridge
373
+ playerAPI.subscribe(toPlayerState: { _, _ in })
374
+ }
375
+
376
+ private func teardownPlayerSubscription() {
377
+ guard let playerAPI = appRemote?.playerAPI else { return }
378
+ playerAPI.delegate = nil
379
+ playerAPI.unsubscribe(toPlayerState: { _, _ in })
380
+ }
381
+
382
+ /// Called by `SpotifyPlayerStateDelegateBridge` when a state update arrives.
383
+ func playerStateDidChange(_ state: any SPTAppRemotePlayerState) {
384
+ onPlayerStateChange?(Self.playerStateToMap(state))
385
+ }
386
+
387
+ // MARK: — User subscription
388
+
389
+ private func setupCapabilitiesSubscription() {
390
+ guard let userAPI = appRemote?.userAPI else { return }
391
+ userAPI.delegate = userCapabilitiesBridge
392
+ userAPI.subscribe(toCapabilityChanges: { _, _ in })
393
+ }
394
+
395
+ private func teardownCapabilitiesSubscription() {
396
+ guard let userAPI = appRemote?.userAPI else { return }
397
+ userAPI.delegate = nil
398
+ userAPI.unsubscribe(toCapabilityChanges: { _, _ in })
399
+ }
400
+
401
+ /// Called by `SpotifyUserCapabilitiesDelegateBridge` on each capabilities update.
402
+ func userCapabilitiesDidChange(_ capabilities: any SPTAppRemoteUserCapabilities) {
403
+ onCapabilitiesChange?(Self.capabilitiesToMap(capabilities))
404
+ }
405
+
406
+ // MARK: — Player transport
407
+
408
+ func playerPlay(uri: String) async throws {
409
+ let playerAPI = try requirePlayerAPI(callsite: "Player.play")
410
+ try await voidPlayerCall(callsite: "Player.play") { playerAPI.play(uri, callback: $0) }
411
+ }
412
+
413
+ func playerPause() async throws {
414
+ let playerAPI = try requirePlayerAPI(callsite: "Player.pause")
415
+ try await voidPlayerCall(callsite: "Player.pause") { playerAPI.pause($0) }
416
+ }
417
+
418
+ func playerResume() async throws {
419
+ let playerAPI = try requirePlayerAPI(callsite: "Player.resume")
420
+ try await voidPlayerCall(callsite: "Player.resume") { playerAPI.resume($0) }
421
+ }
422
+
423
+ func playerSkipNext() async throws {
424
+ let playerAPI = try requirePlayerAPI(callsite: "Player.skipNext")
425
+ try await voidPlayerCall(callsite: "Player.skipNext") { playerAPI.skip(toNext: $0) }
426
+ }
427
+
428
+ func playerSkipPrevious() async throws {
429
+ let playerAPI = try requirePlayerAPI(callsite: "Player.skipPrevious")
430
+ try await voidPlayerCall(callsite: "Player.skipPrevious") { playerAPI.skip(toPrevious: $0) }
431
+ }
432
+
433
+ func playerSeekTo(positionMs: Int) async throws {
434
+ let playerAPI = try requirePlayerAPI(callsite: "Player.seekTo")
435
+ try await voidPlayerCall(callsite: "Player.seekTo") { playerAPI.seek(toPosition: positionMs, callback: $0) }
436
+ }
437
+
438
+ func playerSetShuffle(enabled: Bool) async throws {
439
+ let playerAPI = try requirePlayerAPI(callsite: "Player.setShuffle")
440
+ try await voidPlayerCall(callsite: "Player.setShuffle") { playerAPI.setShuffle(enabled, callback: $0) }
441
+ }
442
+
443
+ func playerSetRepeatMode(mode: Int) async throws {
444
+ let playerAPI = try requirePlayerAPI(callsite: "Player.setRepeatMode")
445
+ guard let repeatMode = SPTAppRemotePlaybackOptionsRepeatMode(rawValue: UInt(mode)) else {
446
+ throw NativePlayerError.invalidParameter("Player.setRepeatMode: invalid mode \(mode) — must be 0 (off), 1 (track), or 2 (context)")
447
+ }
448
+ try await voidPlayerCall(callsite: "Player.setRepeatMode") { playerAPI.setRepeatMode(repeatMode, callback: $0) }
449
+ }
450
+
451
+ func playerQueue(uri: String) async throws {
452
+ let playerAPI = try requirePlayerAPI(callsite: "Player.queue")
453
+ try await voidPlayerCall(callsite: "Player.queue") { playerAPI.enqueueTrackUri(uri, callback: $0) }
454
+ }
455
+
456
+ func playerGetPlayerState() async throws -> [String: Any] {
457
+ let playerAPI = try requirePlayerAPI(callsite: "Player.getPlayerState")
458
+ return try await withCheckedThrowingContinuation { cont in
459
+ playerAPI.getPlayerState { result, error in
460
+ if let error = error {
461
+ cont.resume(throwing: Self.normalizePlayerError(error as NSError, callsite: "Player.getPlayerState"))
462
+ } else if let state = result as? any SPTAppRemotePlayerState {
463
+ cont.resume(returning: Self.playerStateToMap(state))
464
+ } else {
465
+ cont.resume(throwing: NativePlayerError.unknown("Player.getPlayerState: unexpected result type"))
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ func playerGetCrossfadeState() async throws -> [String: Any] {
472
+ let playerAPI = try requirePlayerAPI(callsite: "Player.getCrossfadeState")
473
+ return try await withCheckedThrowingContinuation { cont in
474
+ playerAPI.getCrossfadeState { result, error in
475
+ if let error = error {
476
+ cont.resume(throwing: Self.normalizePlayerError(error as NSError, callsite: "Player.getCrossfadeState"))
477
+ } else if let state = result as? any SPTAppRemoteCrossfadeState {
478
+ cont.resume(returning: ["isEnabled": state.isEnabled, "duration": state.duration])
479
+ } else {
480
+ cont.resume(throwing: NativePlayerError.unknown("Player.getCrossfadeState: unexpected result type"))
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ func playerSetPodcastPlaybackSpeed(value: Float) async throws {
487
+ let playerAPI = try requirePlayerAPI(callsite: "Player.setPodcastPlaybackSpeed")
488
+ // Fetch SDK-vended speed objects to find the one matching `value`.
489
+ let speeds: [any SPTAppRemotePodcastPlaybackSpeed] = try await withCheckedThrowingContinuation { cont in
490
+ playerAPI.getAvailablePodcastPlaybackSpeeds { result, error in
491
+ if let error = error {
492
+ cont.resume(throwing: Self.normalizePlayerError(error as NSError, callsite: "Player.setPodcastPlaybackSpeed"))
493
+ } else if let speeds = result as? [any SPTAppRemotePodcastPlaybackSpeed] {
494
+ cont.resume(returning: speeds)
495
+ } else {
496
+ cont.resume(throwing: NativePlayerError.unknown("Player.setPodcastPlaybackSpeed: unexpected result from getAvailablePodcastPlaybackSpeeds"))
497
+ }
498
+ }
499
+ }
500
+ guard let speed = speeds.first(where: { abs($0.value.floatValue - value) < 0.01 }) else {
501
+ let available = speeds.map { $0.value.floatValue }
502
+ throw NativePlayerError.invalidParameter(
503
+ "Player.setPodcastPlaybackSpeed: unsupported speed \(value). Available: \(available)"
504
+ )
505
+ }
506
+ try await voidPlayerCall(callsite: "Player.setPodcastPlaybackSpeed") {
507
+ playerAPI.setPodcastPlaybackSpeed(speed, callback: $0)
508
+ }
509
+ }
510
+
511
+ // MARK: — User operations
512
+
513
+ func userGetCapabilities() async throws -> [String: Any] {
514
+ let userAPI = try requireUserAPI(callsite: "User.getCapabilities")
515
+ return try await withCheckedThrowingContinuation { cont in
516
+ userAPI.fetchCapabilities { result, error in
517
+ if let error = error {
518
+ cont.resume(throwing: Self.normalizeUserError(error as NSError, callsite: "User.getCapabilities"))
519
+ } else if let capabilities = result as? any SPTAppRemoteUserCapabilities {
520
+ cont.resume(returning: Self.capabilitiesToMap(capabilities))
521
+ } else {
522
+ cont.resume(throwing: NativeUserError.unknown("User.getCapabilities: unexpected result type"))
523
+ }
524
+ }
525
+ }
526
+ }
527
+
528
+ func userGetLibraryState(uri: String) async throws -> [String: Any] {
529
+ let userAPI = try requireUserAPI(callsite: "User.getLibraryState")
530
+ return try await withCheckedThrowingContinuation { cont in
531
+ userAPI.fetchLibraryState(forURI: uri) { result, error in
532
+ if let error = error {
533
+ cont.resume(throwing: Self.normalizeUserError(error as NSError, callsite: "User.getLibraryState"))
534
+ } else if let state = result as? any SPTAppRemoteLibraryState {
535
+ cont.resume(returning: Self.libraryStateToMap(state))
536
+ } else {
537
+ cont.resume(throwing: NativeUserError.unknown("User.getLibraryState: unexpected result type"))
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ func userAddToLibrary(uri: String) async throws -> [String: Any] {
544
+ let userAPI = try requireUserAPI(callsite: "User.addToLibrary")
545
+ return try await withCheckedThrowingContinuation { cont in
546
+ userAPI.addItemToLibrary(withURI: uri) { result, error in
547
+ if let error = error {
548
+ cont.resume(throwing: Self.normalizeUserError(error as NSError, callsite: "User.addToLibrary"))
549
+ } else if let state = result as? any SPTAppRemoteLibraryState {
550
+ cont.resume(returning: Self.libraryStateToMap(state))
551
+ } else {
552
+ cont.resume(throwing: NativeUserError.unknown("User.addToLibrary: unexpected result type"))
553
+ }
554
+ }
555
+ }
556
+ }
557
+
558
+ func userRemoveFromLibrary(uri: String) async throws -> [String: Any] {
559
+ let userAPI = try requireUserAPI(callsite: "User.removeFromLibrary")
560
+ return try await withCheckedThrowingContinuation { cont in
561
+ userAPI.removeItemFromLibrary(withURI: uri) { result, error in
562
+ if let error = error {
563
+ cont.resume(throwing: Self.normalizeUserError(error as NSError, callsite: "User.removeFromLibrary"))
564
+ } else if let state = result as? any SPTAppRemoteLibraryState {
565
+ cont.resume(returning: Self.libraryStateToMap(state))
566
+ } else {
567
+ cont.resume(throwing: NativeUserError.unknown("User.removeFromLibrary: unexpected result type"))
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ // MARK: — Content operations
574
+
575
+ func contentGetRecommendedContentItems(type: String) async throws -> [[String: Any]] {
576
+ let contentAPI = try requireContentAPI(callsite: "Content.getRecommendedContentItems")
577
+ return try await withCheckedThrowingContinuation { cont in
578
+ contentAPI.fetchRecommendedContentItems(forType: Self.mapContentType(type), flattenContainers: false) { result, error in
579
+ if let error = error {
580
+ cont.resume(throwing: Self.normalizeContentError(error as NSError, callsite: "Content.getRecommendedContentItems"))
581
+ } else if let list = result as? [any SPTAppRemoteContentItem] {
582
+ cont.resume(returning: list.map(Self.contentItemToMap))
583
+ } else {
584
+ cont.resume(throwing: NativeContentError.unknown("Content.getRecommendedContentItems: unexpected result type"))
585
+ }
586
+ }
587
+ }
588
+ }
589
+
590
+ func contentGetChildren(itemMap: [String: Any]) async throws -> [[String: Any]] {
591
+ let contentAPI = try requireContentAPI(callsite: "Content.getChildren")
592
+ guard let uri = itemMap["uri"] as? String, !uri.isEmpty else {
593
+ throw NativeContentError.unknown("Content.getChildren: missing item uri")
594
+ }
595
+
596
+ let contentItem: any SPTAppRemoteContentItem = try await withCheckedThrowingContinuation { cont in
597
+ contentAPI.fetchContentItem(forURI: uri) { result, error in
598
+ if let error = error {
599
+ cont.resume(throwing: Self.normalizeContentError(error as NSError, callsite: "Content.getChildren"))
600
+ } else if let item = result as? any SPTAppRemoteContentItem {
601
+ cont.resume(returning: item)
602
+ } else {
603
+ cont.resume(throwing: NativeContentError.unknown("Content.getChildren: failed to resolve content item from URI"))
604
+ }
605
+ }
606
+ }
607
+
608
+ return try await withCheckedThrowingContinuation { cont in
609
+ contentAPI.fetchChildren(of: contentItem, callback: { result, error in
610
+ if let error = error {
611
+ cont.resume(throwing: Self.normalizeContentError(error as NSError, callsite: "Content.getChildren"))
612
+ } else if let children = result as? [any SPTAppRemoteContentItem] {
613
+ cont.resume(returning: children.map(Self.contentItemToMap))
614
+ } else {
615
+ cont.resume(throwing: NativeContentError.unknown("Content.getChildren: unexpected result type"))
616
+ }
617
+ })
618
+ }
619
+ }
620
+
621
+ // MARK: — Images operations
622
+
623
+ func imagesLoad(imageIdentifier: String, size: String) async throws -> [String: Any] {
624
+ guard !imageIdentifier.isEmpty else {
625
+ throw NativeImagesError.invalidURI("Images.load: imageIdentifier must be non-empty")
626
+ }
627
+ let imageAPI = try requireImageAPI(callsite: "Images.load")
628
+ let representable = LocalImageRepresentable(imageIdentifier: imageIdentifier)
629
+ let targetSize = Self.mapImageSize(size)
630
+
631
+ let image: UIImage = try await withCheckedThrowingContinuation { cont in
632
+ imageAPI.fetchImage(forItem: representable, with: targetSize) { result, error in
633
+ if let error = error {
634
+ cont.resume(throwing: Self.normalizeImagesError(error as NSError, callsite: "Images.load"))
635
+ } else if let image = result as? UIImage {
636
+ cont.resume(returning: image)
637
+ } else {
638
+ cont.resume(throwing: NativeImagesError.imageLoadFailed("Images.load: unexpected image result type"))
639
+ }
640
+ }
641
+ }
642
+
643
+ let data = image.pngData() ?? image.jpegData(compressionQuality: 1.0)
644
+ guard let bytes = data else {
645
+ throw NativeImagesError.imageLoadFailed("Images.load: failed to encode image data")
646
+ }
647
+ let filename = "expo-spotify-image-\(UUID().uuidString).png"
648
+ let path = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename)
649
+ do {
650
+ try bytes.write(to: path, options: .atomic)
651
+ return ["uri": path.absoluteString]
652
+ } catch {
653
+ throw NativeImagesError.imageLoadFailed("Images.load: failed to write temp image file")
654
+ }
655
+ }
656
+
657
+ // MARK: — Internal helpers
658
+
659
+ private func requirePlayerAPI(callsite: String) throws -> any SPTAppRemotePlayerAPI {
660
+ guard let remote = appRemote, remote.isConnected, let playerAPI = remote.playerAPI else {
661
+ throw NativePlayerError.notConnected(
662
+ "\(callsite): requires an active App Remote connection — call AppRemote.connect() first"
663
+ )
664
+ }
665
+ return playerAPI
666
+ }
667
+
668
+ private func requireUserAPI(callsite: String) throws -> any SPTAppRemoteUserAPI {
669
+ guard let remote = appRemote, remote.isConnected, let userAPI = remote.userAPI else {
670
+ throw NativeUserError.notConnected(
671
+ "\(callsite): requires an active App Remote connection — call AppRemote.connect() first"
672
+ )
673
+ }
674
+ return userAPI
675
+ }
676
+
677
+ private func requireContentAPI(callsite: String) throws -> any SPTAppRemoteContentAPI {
678
+ guard let remote = appRemote, remote.isConnected, let contentAPI = remote.contentAPI else {
679
+ throw NativeContentError.notConnected(
680
+ "\(callsite): requires an active App Remote connection — call AppRemote.connect() first"
681
+ )
682
+ }
683
+ return contentAPI
684
+ }
685
+
686
+ private func requireImageAPI(callsite: String) throws -> any SPTAppRemoteImageAPI {
687
+ guard let remote = appRemote, remote.isConnected, let imageAPI = remote.imageAPI else {
688
+ throw NativeImagesError.notConnected(
689
+ "\(callsite): requires an active App Remote connection — call AppRemote.connect() first"
690
+ )
691
+ }
692
+ return imageAPI
693
+ }
694
+
695
+ private func voidPlayerCall(
696
+ callsite: String,
697
+ block: (@escaping SPTAppRemoteCallback) -> Void
698
+ ) async throws {
699
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
700
+ block { _, error in
701
+ if let error = error {
702
+ cont.resume(throwing: Self.normalizePlayerError(error as NSError, callsite: callsite))
703
+ } else {
704
+ cont.resume()
705
+ }
706
+ }
707
+ }
708
+ }
709
+
710
+ private static func normalizePlayerError(_ error: NSError, callsite: String) -> NativePlayerError {
711
+ guard error.domain == SPTAppRemoteErrorDomain else {
712
+ return .unknown(error.localizedDescription)
713
+ }
714
+ switch error.code {
715
+ case SPTAppRemoteErrorCode.connectionTerminatedError.rawValue:
716
+ return .connectionLost("\(callsite): connection to Spotify app was terminated")
717
+ case SPTAppRemoteErrorCode.invalidArgumentsError.rawValue:
718
+ return .invalidParameter(error.localizedDescription)
719
+ case SPTAppRemoteErrorCode.requestFailedError.rawValue:
720
+ let desc = error.localizedDescription.lowercased()
721
+ if desc.contains("premium") {
722
+ return .premiumRequired("\(callsite): Spotify Premium is required for on-demand playback")
723
+ }
724
+ if desc.contains("not allowed") || desc.contains("restriction") {
725
+ return .operationNotAllowed("\(callsite): \(error.localizedDescription)")
726
+ }
727
+ return .unknown(error.localizedDescription)
728
+ default:
729
+ return .unknown(error.localizedDescription)
730
+ }
731
+ }
732
+
733
+ private static func playerStateToMap(_ state: any SPTAppRemotePlayerState) -> [String: Any] {
734
+ let track = state.track
735
+ let restrictions = state.playbackRestrictions
736
+ let options = state.playbackOptions
737
+ return [
738
+ "track": [
739
+ "uri": track.uri,
740
+ "name": track.name,
741
+ "imageIdentifier": track.imageIdentifier,
742
+ "duration": track.duration,
743
+ "artist": ["name": track.artist.name, "uri": track.artist.uri],
744
+ "album": ["name": track.album.name, "uri": track.album.uri],
745
+ "isSaved": track.isSaved,
746
+ "isEpisode": track.isEpisode,
747
+ "isPodcast": track.isPodcast,
748
+ "isAdvertisement": track.isAdvertisement,
749
+ ] as [String: Any],
750
+ "playbackPosition": state.playbackPosition,
751
+ "playbackSpeed": state.playbackSpeed,
752
+ "isPaused": state.isPaused,
753
+ "playbackOptions": [
754
+ "isShuffling": options.isShuffling,
755
+ "repeatMode": options.repeatMode.rawValue,
756
+ ] as [String: Any],
757
+ "playbackRestrictions": [
758
+ "canSkipNext": restrictions.canSkipNext,
759
+ "canSkipPrevious": restrictions.canSkipPrevious,
760
+ "canRepeatTrack": restrictions.canRepeatTrack,
761
+ "canRepeatContext": restrictions.canRepeatContext,
762
+ "canToggleShuffle": restrictions.canToggleShuffle,
763
+ "canSeek": restrictions.canSeek,
764
+ ] as [String: Any],
765
+ "contextTitle": state.contextTitle,
766
+ "contextUri": state.contextURI.absoluteString,
767
+ ]
768
+ }
769
+
770
+ private static func capabilitiesToMap(_ capabilities: any SPTAppRemoteUserCapabilities) -> [String: Any] {
771
+ ["canPlayOnDemand": capabilities.canPlayOnDemand]
772
+ }
773
+
774
+ private static func libraryStateToMap(_ state: any SPTAppRemoteLibraryState) -> [String: Any] {
775
+ ["uri": state.uri, "isAdded": state.isAdded, "canAdd": state.canAdd]
776
+ }
777
+
778
+ private static func normalizeUserError(_ error: NSError, callsite: String) -> NativeUserError {
779
+ guard error.domain == SPTAppRemoteErrorDomain else {
780
+ return .unknown(error.localizedDescription)
781
+ }
782
+ switch error.code {
783
+ case SPTAppRemoteErrorCode.connectionTerminatedError.rawValue:
784
+ return .connectionLost("\(callsite): connection to Spotify app was terminated")
785
+ case SPTAppRemoteErrorCode.invalidArgumentsError.rawValue:
786
+ return .invalidURI("\(callsite): \(error.localizedDescription)")
787
+ case SPTAppRemoteErrorCode.requestFailedError.rawValue:
788
+ let desc = error.localizedDescription.lowercased()
789
+ if desc.contains("not allowed") || desc.contains("restriction") {
790
+ return .operationNotAllowed("\(callsite): \(error.localizedDescription)")
791
+ }
792
+ return .unknown(error.localizedDescription)
793
+ default:
794
+ return .unknown(error.localizedDescription)
795
+ }
796
+ }
797
+
798
+ private static func normalizeContentError(_ error: NSError, callsite: String) -> NativeContentError {
799
+ guard error.domain == SPTAppRemoteErrorDomain else {
800
+ return .unknown(error.localizedDescription)
801
+ }
802
+ switch error.code {
803
+ case SPTAppRemoteErrorCode.connectionTerminatedError.rawValue:
804
+ return .connectionLost("\(callsite): connection to Spotify app was terminated")
805
+ case SPTAppRemoteErrorCode.requestFailedError.rawValue:
806
+ let desc = error.localizedDescription.lowercased()
807
+ if desc.contains("not supported") || desc.contains("unsupported") {
808
+ return .contentAPIUnavailable("\(callsite): content API is unavailable on this Spotify app version")
809
+ }
810
+ return .unknown(error.localizedDescription)
811
+ default:
812
+ return .unknown(error.localizedDescription)
813
+ }
814
+ }
815
+
816
+ private static func normalizeImagesError(_ error: NSError, callsite: String) -> NativeImagesError {
817
+ guard error.domain == SPTAppRemoteErrorDomain else {
818
+ return .unknown(error.localizedDescription)
819
+ }
820
+ switch error.code {
821
+ case SPTAppRemoteErrorCode.connectionTerminatedError.rawValue:
822
+ return .notConnected("\(callsite): connection to Spotify app was terminated")
823
+ case SPTAppRemoteErrorCode.invalidArgumentsError.rawValue:
824
+ return .invalidURI("\(callsite): invalid image identifier")
825
+ case SPTAppRemoteErrorCode.requestFailedError.rawValue:
826
+ return .imageLoadFailed("\(callsite): Spotify rejected image request")
827
+ default:
828
+ return .unknown(error.localizedDescription)
829
+ }
830
+ }
831
+
832
+ private static func mapContentType(_ type: String) -> String {
833
+ switch type {
834
+ case "navigation": return SPTAppRemoteContentTypeNavigation
835
+ case "fitness": return SPTAppRemoteContentTypeFitness
836
+ case "gaming": return SPTAppRemoteContentTypeGaming
837
+ default: return SPTAppRemoteContentTypeDefault
838
+ }
839
+ }
840
+
841
+ private static func mapImageSize(_ size: String) -> CGSize {
842
+ switch size {
843
+ case "small": return CGSize(width: 64, height: 64)
844
+ case "medium": return CGSize(width: 300, height: 300)
845
+ default: return CGSize(width: 640, height: 640)
846
+ }
847
+ }
848
+
849
+ private static func contentItemToMap(_ item: any SPTAppRemoteContentItem) -> [String: Any] {
850
+ var map: [String: Any] = [
851
+ "title": item.title as Any,
852
+ "subtitle": item.subtitle as Any,
853
+ "contentDescription": item.contentDescription as Any,
854
+ "identifier": item.identifier,
855
+ "uri": item.uri,
856
+ "imageIdentifier": item.imageIdentifier,
857
+ "isAvailableOffline": item.isAvailableOffline,
858
+ "isPlayable": item.isPlayable,
859
+ "isContainer": item.isContainer,
860
+ "isPinned": item.isPinned,
861
+ ]
862
+ if let children = item.children {
863
+ map["children"] = children.map(Self.contentItemToMap)
864
+ }
865
+ return map
866
+ }
867
+
868
+ private func transitionState(_ state: String) {
869
+ connectionStateString = state
870
+ onConnectionStateChange?(state)
871
+ }
872
+
873
+ private func describeNSError(_ error: NSError) -> String {
874
+ var parts: [String] = ["\(error.domain) code \(error.code)"]
875
+ let desc = error.localizedDescription
876
+ if !desc.isEmpty { parts.append("\"\(desc)\"") }
877
+ if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
878
+ parts.append("→ \(describeNSError(underlying))")
879
+ }
880
+ return parts.joined(separator: " ")
881
+ }
882
+ }
883
+
884
+ // MARK: — Connection delegate bridge
885
+
886
+ /// `SPTAppRemoteDelegate` requires `NSObject` conformance, which an `actor`
887
+ /// cannot satisfy directly. This bridge is a tiny `NSObject` that hops each
888
+ /// delegate callback onto the actor's executor via a `Task`.
889
+ final class SpotifyAppRemoteDelegateBridge: NSObject, SPTAppRemoteDelegate {
890
+ weak var coordinator: SpotifyAppRemoteCoordinator?
891
+
892
+ func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) {
893
+ NSLog("[ExpoSpotifySDK] AppRemote: connection established")
894
+ Task { await coordinator?.didConnect() }
895
+ }
896
+
897
+ func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: (any Error)?) {
898
+ NSLog("[ExpoSpotifySDK] AppRemote: connection failed — %@", String(describing: error))
899
+ Task { await coordinator?.didFailToConnect(error: error) }
900
+ }
901
+
902
+ func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: (any Error)?) {
903
+ NSLog("[ExpoSpotifySDK] AppRemote: disconnected — %@", String(describing: error))
904
+ Task { await coordinator?.didDisconnect(error: error) }
905
+ }
906
+ }
907
+
908
+ // MARK: — Player state delegate bridge
909
+
910
+ /// `SPTAppRemotePlayerStateDelegate` requires `NSObject` conformance. This
911
+ /// bridge forwards player state updates to the actor coordinator.
912
+ final class SpotifyPlayerStateDelegateBridge: NSObject, SPTAppRemotePlayerStateDelegate {
913
+ weak var coordinator: SpotifyAppRemoteCoordinator?
914
+
915
+ func playerStateDidChange(_ playerState: any SPTAppRemotePlayerState) {
916
+ Task { await coordinator?.playerStateDidChange(playerState) }
917
+ }
918
+ }
919
+
920
+ // MARK: — User capabilities delegate bridge
921
+
922
+ final class SpotifyUserCapabilitiesDelegateBridge: NSObject, SPTAppRemoteUserAPIDelegate {
923
+ weak var coordinator: SpotifyAppRemoteCoordinator?
924
+
925
+ func userAPI(_ userAPI: any SPTAppRemoteUserAPI, didReceive capabilities: any SPTAppRemoteUserCapabilities) {
926
+ Task { await coordinator?.userCapabilitiesDidChange(capabilities) }
927
+ }
928
+ }
929
+
930
+ // MARK: — Local image wrapper
931
+
932
+ final class LocalImageRepresentable: NSObject, SPTAppRemoteImageRepresentable {
933
+ let imageIdentifier: String
934
+
935
+ init(imageIdentifier: String) {
936
+ self.imageIdentifier = imageIdentifier
937
+ super.init()
938
+ }
939
+ }