-
Notifications
You must be signed in to change notification settings - Fork 0
/
SpotifyController.swift
194 lines (168 loc) · 7.33 KB
/
SpotifyController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
//
// SpotifyController.swift
// ControlKit
//
import OSLog
import SpotifyiOS
import SwiftUI
/// 🍏 Manage playback from the **Spotify** app and subscribe to its playback state.
///
/// This class facilitates the connection to and interaction with the Spotify app, enabling playback control and handling the authorization flow.
public final class SpotifyController: NSObject, ObservableObject {
@Published public private(set) var isPlaying: Bool = false
private lazy var remote: SPTAppRemote = {
let remote = SPTAppRemote(
configuration: SPTConfiguration(
clientID: config.clientID,
redirectURL: config.redirectURL
),
logLevel: config.logLevel
)
do {
remote.connectionParameters.accessToken = try accessTokenDAO?.get()
} catch {
Self.log.debug("Access token not set: \(error.localizedDescription)")
}
remote.delegate = self
remote.playerAPI?.delegate = self
return remote
}()
private let config: SpotifyConfig
private let accessTokenDAO: (any DAO<String>)?
/// The main initializer for `SpotifyController`.
///
/// - Parameters:
/// - config: The `SpotifyConfig` object used to configure the controller.
/// If `config` is `.empty`, the controller will not attempt to connect automatically.
/// The default log level is **`SPTAppRemoteLogLevel.debug`**.
/// - accessTokenDAO: An object conforming to `DAO<String>` that persists the Spotify access token. By default, this optional parameter
/// is nil and the access token is not persisted across app launches.
/// - autoConnect: A Boolean value that determines whether the controller should attempt to connect to Spotify automatically upon initialization.
/// The default value is `true`. If `true` and `config` is not `.empty`, the controller will initiate the connection.
///
/// - Warning: Saving the Spotify access token (_or any other sensitive informative)_ using **`UserDefaults`** is not recommended for production apps.
/// Prefer providing a `DAO` that persists values to the Keychain 🔐
///
public init(
config: SpotifyConfig,
accessTokenDAO: (any DAO<String>)? = nil,
autoConnect: Bool = true
) {
self.config = config
self.accessTokenDAO = accessTokenDAO
super.init()
if autoConnect && config != .empty {
connect()
}
}
/// Deep links to the Spotify app where the authorization flow is completed before reopening the host app.
///
/// Use `setAccessToken(from url:)` in a SwiftUI `View.onOpenURL(perform:)` modifier to parse and persist the access token.
/// This will be called when the Spotify app completes the authorization flow as it uses the `redirectURL` provided in the configuration
/// to deep link back to the app that initiated the authorization flow.
public func authorize() {
// Using an empty string here will attempt to play the user's last song
self.remote.authorizeAndPlayURI("")
}
/// Parses the provided URL to extract and assign the access token, optionally persisting it across app launches.
///
/// This method does three things:
/// - Parses the provided URL
/// - Assigns the access token (if found) to the local `SPTAppRemote.connectionParameters.accessToken`
/// - Attempts to persist the access token using the optional `DAO<String>` property.
///
/// Use this inside a SwiftUI onOpenURL modifier to parse and save the access token from the URL callback.
/// ```
/// .onOpenURL { systemController.spotify.setAccessToken(from: $0) }
/// ```
/// - Important: The access token will **not be persisted** across app launches if an `accessTokenDAO` parameter
/// is not provided when initializing `SpotifyController`.
public func setAccessToken(from url: URL) {
guard
let parameters = remote.authorizationParameters(from: url),
let newToken = parameters[SPTAppRemoteAccessTokenKey]
else {
Self.log.error("Failed to parse access token from URL: \(url, privacy: .private)")
return
}
do {
try accessTokenDAO?.save(newToken)
} catch {
Self.log.warning("Failed to persist access token: \(error.localizedDescription)")
}
remote.connectionParameters.accessToken = newToken
}
/// Creates a connection (subscription) to the Spotify app.
public func connect() {
guard remote.connectionParameters.accessToken != nil else {
Self.log.warning("Attempting to connect to Spotify without first setting access token")
return
}
remote.connect()
}
/// Disconnect from the Spotify app.
///
/// Call this when your app is terminating (scene == .inactive).
public func disconnect() {
guard remote.isConnected else {
Self.log.warning("Attempting to disconnect from Spotify app but remote is not connected")
return
}
remote.disconnect()
do {
try accessTokenDAO?.delete()
} catch {
Self.log.warning("Failed to delete access token: \(error.localizedDescription)")
}
}
}
extension SpotifyController: PlaybackController {
/// Toggles playback and updates the ``isPlaying`` **`@Published`** property.
public func togglePlayPause() {
remote.togglePlayPause()
}
/// Sends the "next track command" to the **Spotify** app.
public func skipToNextTrack() {
remote.playerAPI?.skip(toNext: nil)
}
/// Sends the "previous track command" to the **Spotify** app.
public func skipToPreviousTrack() {
remote.playerAPI?.skip(toPrevious: nil)
}
}
extension SpotifyController: SPTAppRemoteDelegate {
public func appRemoteDidEstablishConnection(_ appRemote: SPTAppRemote) {
SpotifyController.log.info("SPTAppRemoteDelegate.appRemoteDidEstablishConnection")
}
public func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: (any Error)?) {
SpotifyController.log.info("SPTAppRemoteDelegate.didFailConnectionAttemptWithError")
}
public func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: (any Error)?) {
SpotifyController.log.info("SPTAppRemoteDelegate.didDisconnectWithError")
}
}
extension SpotifyController: SPTAppRemotePlayerStateDelegate {
public func playerStateDidChange(_ playerState: any SPTAppRemotePlayerState) {
isPlaying = !playerState.isPaused
}
}
extension SpotifyController {
public struct SpotifyConfig: Equatable {
let clientID: String
let logLevel: SPTAppRemoteLogLevel
let redirectURL: URL
public init(
clientID: String,
logLevel: SPTAppRemoteLogLevel = .info,
redirectURL: String
) {
self.clientID = clientID
self.logLevel = logLevel
self.redirectURL = URL(string: redirectURL)!
}
public static var empty: Self {
.init(clientID: "123", redirectURL: "456")
}
}
private static let log = Logger(subsystem: Controllers.subsystem, category: "SpotifyController")
}