@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.
- package/README.md +378 -138
- 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 +146 -9
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyAppRemoteCoordinator.kt +556 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +112 -0
- 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 -56
- package/build/index.d.ts.map +1 -1
- package/build/index.js +49 -141
- 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 +227 -5
- package/ios/SpotifyAppRemoteCoordinator.swift +939 -0
- package/ios/SpotifyAuthCoordinator.swift +53 -13
- package/ios/SpotifyTokenRefreshClient.swift +28 -0
- 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
|
+
}
|