diff --git a/IrisCompanion/iris/ContentView.swift b/IrisCompanion/iris/ContentView.swift index a89accf..ed018ea 100644 --- a/IrisCompanion/iris/ContentView.swift +++ b/IrisCompanion/iris/ContentView.swift @@ -16,6 +16,8 @@ struct ContentView: View { .tabItem { Label("BLE", systemImage: "dot.radiowaves.left.and.right") } OrchestratorView() .tabItem { Label("Orchestrator", systemImage: "bolt.horizontal.circle") } + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") } } .onAppear { orchestrator.start() } } @@ -25,10 +27,12 @@ struct ContentView_Previews: PreviewProvider { static var previews: some View { if #available(iOS 16.0, *) { let ble = BlePeripheralManager() - let orchestrator = ContextOrchestrator(ble: ble) + let spotifyAuth = SpotifyAuthManager() + let orchestrator = ContextOrchestrator(ble: ble, spotifyAuth: spotifyAuth) ContentView() .environmentObject(ble) .environmentObject(orchestrator) + .environmentObject(spotifyAuth) } else { ContentView() } diff --git a/IrisCompanion/iris/Info.plist b/IrisCompanion/iris/Info.plist index 477e4e9..edb9e1b 100644 --- a/IrisCompanion/iris/Info.plist +++ b/IrisCompanion/iris/Info.plist @@ -2,6 +2,17 @@ + CFBundleURLTypes + + + CFBundleURLName + sh.nym.iris.spotify-auth + CFBundleURLSchemes + + iris-spotify-auth + + + MKDirectionsApplicationSupportedModes NSCalendarsFullAccessUsageDescription diff --git a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift index a16c9bb..e57c86c 100644 --- a/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift +++ b/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift @@ -26,6 +26,15 @@ final class ContextOrchestrator: NSObject, ObservableObject { @Published private(set) var lastFetchFailed: Bool = false @Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined @Published private(set) var nowPlaying: NowPlayingSnapshot? = nil + @Published private(set) var spotifyNowPlaying: SpotifyNowPlaying? = nil + @Published var musicSource: MusicSource = .appleMusic { + didSet { + if oldValue != musicSource { + switchMusicMonitor() + UserDefaults.standard.set(musicSource.rawValue, forKey: "music_source") + } + } + } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator") @@ -38,6 +47,8 @@ final class ContextOrchestrator: NSObject, ObservableObject { private let server: LocalServer private let ble: BlePeripheralManager private let nowPlayingMonitor = NowPlayingMonitor() + private let spotifyMonitor: SpotifyNowPlayingMonitor + private let spotifyAuth: SpotifyAuthManager private var lastRecomputeLocation: CLLocation? = nil private var lastRecomputeAccuracy: CLLocationAccuracy? = nil @@ -46,11 +57,20 @@ final class ContextOrchestrator: NSObject, ObservableObject { init(store: FeedStore = FeedStore(), server: LocalServer = LocalServer(), - ble: BlePeripheralManager) { + ble: BlePeripheralManager, + spotifyAuth: SpotifyAuthManager) { self.store = store self.server = server self.ble = ble + self.spotifyAuth = spotifyAuth + self.spotifyMonitor = SpotifyNowPlayingMonitor(authManager: spotifyAuth) self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(feedItemId: id) }) + + if let savedSource = UserDefaults.standard.string(forKey: "music_source"), + let source = MusicSource(rawValue: savedSource) { + self.musicSource = source + } + super.init() locationManager.delegate = self @@ -73,13 +93,21 @@ final class ContextOrchestrator: NSObject, ObservableObject { nowPlayingMonitor.onUpdate = { [weak self] update in Task { @MainActor in - guard let self else { return } + guard let self, self.musicSource == .appleMusic else { return } self.musicAuthorization = update.authorization self.nowPlaying = update.snapshot self.pushLatestFeedToBle() } } + spotifyMonitor.onUpdate = { [weak self] update in + Task { @MainActor in + guard let self, self.musicSource == .spotify else { return } + self.spotifyNowPlaying = update.snapshot + self.pushLatestFeedToBle() + } + } + let feed = store.getFeed() lastFeed = feed } @@ -88,7 +116,7 @@ final class ContextOrchestrator: NSObject, ObservableObject { authorization = locationManager.authorizationStatus logger.info("start auth=\(String(describing: self.authorization), privacy: .public)") server.start() - nowPlayingMonitor.start() + startMusicMonitor() requestPermissionsIfNeeded() locationManager.startUpdatingLocation() } @@ -96,6 +124,27 @@ final class ContextOrchestrator: NSObject, ObservableObject { func stop() { locationManager.stopUpdatingLocation() nowPlayingMonitor.stop() + spotifyMonitor.stop() + } + + private func startMusicMonitor() { + switch musicSource { + case .appleMusic: + nowPlayingMonitor.start() + case .spotify: + spotifyMonitor.start() + } + } + + private func switchMusicMonitor() { + nowPlayingMonitor.stop() + spotifyMonitor.stop() + nowPlaying = nil + spotifyNowPlaying = nil + + startMusicMonitor() + pushLatestFeedToBle() + logger.info("Switched music source to \(self.musicSource.rawValue)") } func recomputeNow(reason: String = "manual") { @@ -500,7 +549,15 @@ final class ContextOrchestrator: NSObject, ObservableObject { } private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope { - guard let nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) else { + let nowPlayingCard: FeedItem? + switch musicSource { + case .appleMusic: + nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) + case .spotify: + nowPlayingCard = spotifyNowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) + } + + guard let nowPlayingCard else { return base } diff --git a/IrisCompanion/iris/Spotify/KeychainHelper.swift b/IrisCompanion/iris/Spotify/KeychainHelper.swift new file mode 100644 index 0000000..36c4dd6 --- /dev/null +++ b/IrisCompanion/iris/Spotify/KeychainHelper.swift @@ -0,0 +1,61 @@ +// +// KeychainHelper.swift +// iris +// +// Secure token storage using iOS Keychain. +// + +import Foundation +import Security + +enum KeychainHelper { + private static let service = "sh.nym.iris.spotify" + + static func save(key: String, data: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } + } + + static func load(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return nil } + return result as? Data + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } + + enum KeychainError: Error { + case saveFailed(OSStatus) + } +} diff --git a/IrisCompanion/iris/Spotify/SpotifyAPIClient.swift b/IrisCompanion/iris/Spotify/SpotifyAPIClient.swift new file mode 100644 index 0000000..c7bf967 --- /dev/null +++ b/IrisCompanion/iris/Spotify/SpotifyAPIClient.swift @@ -0,0 +1,77 @@ +// +// SpotifyAPIClient.swift +// iris +// +// Spotify Web API client for fetching currently playing track. +// + +import Foundation +import os + +@available(iOS 16.0, *) +@MainActor +final class SpotifyAPIClient { + private let authManager: SpotifyAuthManager + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyAPI") + private let baseUrl = "https://api.spotify.com/v1" + + init(authManager: SpotifyAuthManager) { + self.authManager = authManager + } + + func getCurrentPlayback() async throws -> SpotifyPlaybackState? { + try await getCurrentPlayback(isRetry: false) + } + + private func getCurrentPlayback(isRetry: Bool) async throws -> SpotifyPlaybackState? { + let refreshed = await authManager.refreshTokenIfNeeded() + guard refreshed else { + throw SpotifyAPIError.notAuthenticated + } + + guard let token = authManager.accessToken else { + throw SpotifyAPIError.notAuthenticated + } + + var request = URLRequest(url: URL(string: "\(baseUrl)/me/player/currently-playing")!) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SpotifyAPIError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + return try JSONDecoder().decode(SpotifyPlaybackState.self, from: data) + case 204: + return nil + case 401: + guard !isRetry else { + throw SpotifyAPIError.tokenExpired + } + let refreshed = await authManager.refreshTokenIfNeeded() + if refreshed { + return try await getCurrentPlayback(isRetry: true) + } + throw SpotifyAPIError.tokenExpired + case 429: + let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") + logger.warning("Rate limited, retry after: \(retryAfter ?? "unknown")") + throw SpotifyAPIError.rateLimited + default: + logger.error("API error: \(httpResponse.statusCode)") + throw SpotifyAPIError.apiError(httpResponse.statusCode) + } + } + + enum SpotifyAPIError: Error { + case notAuthenticated + case tokenExpired + case invalidResponse + case rateLimited + case apiError(Int) + } +} diff --git a/IrisCompanion/iris/Spotify/SpotifyAuthManager.swift b/IrisCompanion/iris/Spotify/SpotifyAuthManager.swift new file mode 100644 index 0000000..57666aa --- /dev/null +++ b/IrisCompanion/iris/Spotify/SpotifyAuthManager.swift @@ -0,0 +1,263 @@ +// +// SpotifyAuthManager.swift +// iris +// +// OAuth 2.0 PKCE authentication for Spotify Web API. +// + +import AuthenticationServices +import CommonCrypto +import Foundation +import os + +enum SpotifyConfig { + static let clientId = "YOUR_SPOTIFY_CLIENT_ID" + static let redirectUri = "iris-spotify-auth://callback" + static let scopes = "user-read-playback-state user-read-currently-playing" + static let authUrl = "https://accounts.spotify.com/authorize" + static let tokenUrl = "https://accounts.spotify.com/api/token" +} + +@available(iOS 16.0, *) +@MainActor +final class SpotifyAuthManager: NSObject, ObservableObject { + @Published private(set) var isConnected: Bool = false + @Published private(set) var isAuthenticating: Bool = false + @Published private(set) var error: String? = nil + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyAuth") + private let tokensKey = "spotify_tokens" + private var codeVerifier: String? = nil + private var authSession: ASWebAuthenticationSession? = nil + + override init() { + super.init() + loadTokens() + } + + var accessToken: String? { + loadStoredTokens()?.accessToken + } + + func startAuth() { + guard !isAuthenticating else { return } + isAuthenticating = true + error = nil + + let verifier = generateCodeVerifier() + codeVerifier = verifier + let challenge = generateCodeChallenge(verifier: verifier) + + var components = URLComponents(string: SpotifyConfig.authUrl)! + components.queryItems = [ + URLQueryItem(name: "client_id", value: SpotifyConfig.clientId), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "redirect_uri", value: SpotifyConfig.redirectUri), + URLQueryItem(name: "scope", value: SpotifyConfig.scopes), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "code_challenge", value: challenge) + ] + + guard let authUrl = components.url else { + error = "Failed to build auth URL" + isAuthenticating = false + return + } + + let session = ASWebAuthenticationSession( + url: authUrl, + callbackURLScheme: "iris-spotify-auth" + ) { [weak self] callbackUrl, authError in + Task { @MainActor in + self?.handleAuthCallback(callbackUrl: callbackUrl, error: authError) + } + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + authSession = session + + if !session.start() { + error = "Failed to start auth session" + isAuthenticating = false + } + } + + func handleCallback(url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { + error = "Invalid callback URL" + isAuthenticating = false + return + } + + Task { + await exchangeCodeForTokens(code: code) + } + } + + func disconnect() { + KeychainHelper.delete(key: tokensKey) + isConnected = false + logger.info("Disconnected from Spotify") + } + + func refreshTokenIfNeeded() async -> Bool { + guard let tokens = loadStoredTokens() else { return false } + + if !tokens.expiresWithinMinutes { + return true + } + + return await refreshToken(refreshToken: tokens.refreshToken) + } + + private func handleAuthCallback(callbackUrl: URL?, error authError: Error?) { + isAuthenticating = false + authSession = nil + + if let authError = authError as? ASWebAuthenticationSessionError, + authError.code == .canceledLogin { + logger.info("User cancelled Spotify login") + return + } + + if let authError { + error = authError.localizedDescription + return + } + + guard let callbackUrl else { + error = "No callback URL received" + return + } + + handleCallback(url: callbackUrl) + } + + private func exchangeCodeForTokens(code: String) async { + guard let verifier = codeVerifier else { + error = "Missing code verifier" + isAuthenticating = false + return + } + + let body = [ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": SpotifyConfig.redirectUri, + "client_id": SpotifyConfig.clientId, + "code_verifier": verifier + ] + + do { + let tokenResponse = try await postTokenRequest(body: body) + let tokens = SpotifyTokens( + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken ?? "", + expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) + ) + try saveTokens(tokens) + isConnected = true + logger.info("Successfully authenticated with Spotify") + } catch { + self.error = "Token exchange failed: \(error.localizedDescription)" + logger.error("Token exchange failed: \(error.localizedDescription)") + } + + codeVerifier = nil + isAuthenticating = false + } + + private func refreshToken(refreshToken: String) async -> Bool { + let body = [ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "client_id": SpotifyConfig.clientId + ] + + do { + let tokenResponse = try await postTokenRequest(body: body) + let newRefreshToken = tokenResponse.refreshToken ?? refreshToken + let tokens = SpotifyTokens( + accessToken: tokenResponse.accessToken, + refreshToken: newRefreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) + ) + try saveTokens(tokens) + logger.info("Refreshed Spotify token") + return true + } catch { + logger.error("Token refresh failed: \(error.localizedDescription)") + return false + } + } + + private func postTokenRequest(body: [String: String]) async throws -> SpotifyTokenResponse { + var request = URLRequest(url: URL(string: SpotifyConfig.tokenUrl)!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let bodyString = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } + .joined(separator: "&") + request.httpBody = bodyString.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw SpotifyAuthError.tokenRequestFailed(statusCode) + } + + return try JSONDecoder().decode(SpotifyTokenResponse.self, from: data) + } + + private func loadTokens() { + isConnected = loadStoredTokens() != nil + } + + private func loadStoredTokens() -> SpotifyTokens? { + guard let data = KeychainHelper.load(key: tokensKey) else { return nil } + return try? JSONDecoder().decode(SpotifyTokens.self, from: data) + } + + private func saveTokens(_ tokens: SpotifyTokens) throws { + let data = try JSONEncoder().encode(tokens) + try KeychainHelper.save(key: tokensKey, data: data) + } + + private func generateCodeVerifier() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return Data(bytes).base64URLEncodedString() + } + + private func generateCodeChallenge(verifier: String) -> String { + guard let data = verifier.data(using: .utf8) else { return "" } + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { buffer in + _ = CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &hash) + } + return Data(hash).base64URLEncodedString() + } + + enum SpotifyAuthError: Error { + case tokenRequestFailed(Int) + } +} + +@available(iOS 16.0, *) +extension SpotifyAuthManager: ASWebAuthenticationPresentationContextProviding { + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + ASPresentationAnchor() + } +} + +extension Data { + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/IrisCompanion/iris/Spotify/SpotifyModels.swift b/IrisCompanion/iris/Spotify/SpotifyModels.swift new file mode 100644 index 0000000..b6dd589 --- /dev/null +++ b/IrisCompanion/iris/Spotify/SpotifyModels.swift @@ -0,0 +1,91 @@ +// +// SpotifyModels.swift +// iris +// +// Codable models for Spotify Web API responses. +// + +import Foundation + +struct SpotifyTokens: Codable { + let accessToken: String + let refreshToken: String + let expiresAt: Date + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresAt = "expires_at" + } + + var isExpired: Bool { + Date() >= expiresAt + } + + var expiresWithinMinutes: Bool { + Date().addingTimeInterval(5 * 60) >= expiresAt + } +} + +struct SpotifyTokenResponse: Codable { + let accessToken: String + let tokenType: String + let expiresIn: Int + let refreshToken: String? + let scope: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case refreshToken = "refresh_token" + case scope + } +} + +struct SpotifyPlaybackState: Codable { + let isPlaying: Bool + let progressMs: Int? + let item: SpotifyTrack? + + enum CodingKeys: String, CodingKey { + case isPlaying = "is_playing" + case progressMs = "progress_ms" + case item + } +} + +struct SpotifyTrack: Codable { + let id: String + let name: String + let artists: [SpotifyArtist] + let album: SpotifyAlbum + let durationMs: Int + + enum CodingKeys: String, CodingKey { + case id, name, artists, album + case durationMs = "duration_ms" + } +} + +struct SpotifyArtist: Codable { + let id: String + let name: String +} + +struct SpotifyAlbum: Codable { + let id: String + let name: String +} + +enum MusicSource: String, CaseIterable, Codable { + case appleMusic = "apple_music" + case spotify = "spotify" + + var displayName: String { + switch self { + case .appleMusic: return "Apple Music" + case .spotify: return "Spotify" + } + } +} diff --git a/IrisCompanion/iris/Spotify/SpotifyNowPlayingMonitor.swift b/IrisCompanion/iris/Spotify/SpotifyNowPlayingMonitor.swift new file mode 100644 index 0000000..30fc407 --- /dev/null +++ b/IrisCompanion/iris/Spotify/SpotifyNowPlayingMonitor.swift @@ -0,0 +1,145 @@ +// +// SpotifyNowPlayingMonitor.swift +// iris +// +// Polling monitor for Spotify currently playing track. +// + +import Foundation +import os + +@available(iOS 16.0, *) +struct SpotifyNowPlaying: Equatable, Sendable { + let itemId: String + let title: String + let artist: String? + let album: String? + let isPlaying: Bool + + func asFeedItem(baseGeneratedAt: Int, now: Int) -> FeedItem { + let desiredLifetimeSec = 30 + let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec) + + let subtitleParts = [artist, album] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let subtitle = subtitleParts.isEmpty ? "Spotify" : subtitleParts.joined(separator: " • ") + + return FeedItem( + id: "spotify:now:\(itemId)", + type: .nowPlaying, + title: title.truncated(maxLength: TextConstraints.titleMax), + subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax), + priority: isPlaying ? 0.35 : 0.2, + ttlSec: ttl, + condition: nil, + startsAt: nil, + bucket: .fyi, + actions: ["DISMISS"] + ) + } +} + +@available(iOS 16.0, *) +@MainActor +final class SpotifyNowPlayingMonitor { + struct Update: Sendable { + let isConnected: Bool + let snapshot: SpotifyNowPlaying? + } + + var onUpdate: ((Update) -> Void)? = nil + + private let authManager: SpotifyAuthManager + private let apiClient: SpotifyAPIClient + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyNowPlaying") + + private var pollTimer: DispatchSourceTimer? + private var isRunning = false + private var lastSnapshot: SpotifyNowPlaying? = nil + + init(authManager: SpotifyAuthManager) { + self.authManager = authManager + self.apiClient = SpotifyAPIClient(authManager: authManager) + } + + func start() { + guard !isRunning else { return } + isRunning = true + logger.info("Spotify monitor started") + startPolling() + + Task { @MainActor in + await refresh(reason: "start") + } + } + + func stop() { + guard isRunning else { return } + isRunning = false + logger.info("Spotify monitor stopped") + + pollTimer?.cancel() + pollTimer = nil + } + + private func startPolling() { + guard pollTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + 4, repeating: 4) + timer.setEventHandler { [weak self] in + guard let self else { return } + Task { @MainActor in + await self.refresh(reason: "poll") + } + } + timer.resume() + pollTimer = timer + } + + private func refresh(reason: String) async { + guard authManager.isConnected else { + if lastSnapshot != nil { + lastSnapshot = nil + onUpdate?(Update(isConnected: false, snapshot: nil)) + } + return + } + + do { + let playback = try await apiClient.getCurrentPlayback() + + guard let track = playback?.item, playback?.isPlaying == true else { + if lastSnapshot != nil { + lastSnapshot = nil + onUpdate?(Update(isConnected: true, snapshot: nil)) + } + return + } + + let artistName = track.artists.first?.name + let snapshot = SpotifyNowPlaying( + itemId: track.id, + title: track.name, + artist: artistName, + album: track.album.name, + isPlaying: playback?.isPlaying ?? false + ) + + guard snapshot != lastSnapshot else { return } + lastSnapshot = snapshot + onUpdate?(Update(isConnected: true, snapshot: snapshot)) + logger.debug("Spotify now playing: \(snapshot.title)") + + } catch SpotifyAPIClient.SpotifyAPIError.notAuthenticated { + if lastSnapshot != nil { + lastSnapshot = nil + onUpdate?(Update(isConnected: false, snapshot: nil)) + } + } catch SpotifyAPIClient.SpotifyAPIError.rateLimited { + logger.warning("Spotify rate limited, skipping this poll") + } catch { + logger.error("Spotify fetch error: \(error.localizedDescription)") + } + } +} diff --git a/IrisCompanion/iris/Views/SettingsView.swift b/IrisCompanion/iris/Views/SettingsView.swift new file mode 100644 index 0000000..ea43ff9 --- /dev/null +++ b/IrisCompanion/iris/Views/SettingsView.swift @@ -0,0 +1,151 @@ +// +// SettingsView.swift +// iris +// +// Settings UI for music source selection and Spotify connection. +// + +import MusicKit +import SwiftUI + +@available(iOS 16.0, *) +struct SettingsView: View { + @EnvironmentObject private var orchestrator: ContextOrchestrator + @EnvironmentObject private var spotifyAuth: SpotifyAuthManager + + var body: some View { + NavigationStack { + List { + Section("Music Source") { + Picker("Source", selection: Binding( + get: { orchestrator.musicSource }, + set: { newValue in + if newValue == .spotify && !spotifyAuth.isConnected { + return + } + orchestrator.musicSource = newValue + } + )) { + ForEach(MusicSource.allCases, id: \.self) { source in + Text(source.displayName).tag(source) + } + } + .pickerStyle(.segmented) + + if !spotifyAuth.isConnected { + Text("Connect Spotify below to enable it as a source") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section { + if spotifyAuth.isConnected { + HStack { + Label("Connected", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + Spacer() + } + Button("Disconnect", role: .destructive) { + spotifyAuth.disconnect() + if orchestrator.musicSource == .spotify { + orchestrator.musicSource = .appleMusic + } + } + } else { + Button { + spotifyAuth.startAuth() + } label: { + HStack { + Label("Connect to Spotify", systemImage: "link") + Spacer() + if spotifyAuth.isAuthenticating { + ProgressView() + } + } + } + .disabled(spotifyAuth.isAuthenticating) + } + + if let error = spotifyAuth.error { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + } header: { + Text("Spotify") + } footer: { + if !spotifyAuth.isConnected { + Text("Connect your Spotify account to display current track on Glass.") + } + } + + Section { + HStack { + Text("Authorization") + Spacer() + Text(authStatusText(orchestrator.musicAuthorization)) + .foregroundStyle(.secondary) + } + } header: { + Text("Apple Music") + } + + Section { + if let nowPlayingInfo = currentNowPlaying { + VStack(alignment: .leading, spacing: 4) { + Text(nowPlayingInfo.title) + .font(.headline) + if let artist = nowPlayingInfo.artist { + Text(artist) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } else { + Text("Nothing playing") + .foregroundStyle(.secondary) + } + } header: { + Text("Now Playing") + } footer: { + Text("Source: \(orchestrator.musicSource.displayName)") + } + } + .navigationTitle("Settings") + } + } + + private var currentNowPlaying: (title: String, artist: String?)? { + switch orchestrator.musicSource { + case .appleMusic: + guard let np = orchestrator.nowPlaying else { return nil } + return (np.title, np.artist) + case .spotify: + guard let np = orchestrator.spotifyNowPlaying else { return nil } + return (np.title, np.artist) + } + } + + private func authStatusText(_ status: MusicAuthorization.Status) -> String { + switch status { + case .notDetermined: return "Not Determined" + case .denied: return "Denied" + case .restricted: return "Restricted" + case .authorized: return "Authorized" + @unknown default: return "Unknown" + } + } +} + +@available(iOS 16.0, *) +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + let ble = BlePeripheralManager() + let spotifyAuth = SpotifyAuthManager() + let orchestrator = ContextOrchestrator(ble: ble, spotifyAuth: spotifyAuth) + SettingsView() + .environmentObject(orchestrator) + .environmentObject(spotifyAuth) + } +} diff --git a/IrisCompanion/iris/irisApp.swift b/IrisCompanion/iris/irisApp.swift index e2ff870..4657cd5 100644 --- a/IrisCompanion/iris/irisApp.swift +++ b/IrisCompanion/iris/irisApp.swift @@ -12,12 +12,15 @@ struct irisApp: App { @Environment(\.scenePhase) private var scenePhase @StateObject private var ble: BlePeripheralManager @StateObject private var orchestrator: ContextOrchestrator + @StateObject private var spotifyAuth: SpotifyAuthManager init() { let bleManager = BlePeripheralManager() bleManager.start() + let spotify = SpotifyAuthManager() _ble = StateObject(wrappedValue: bleManager) - _orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager)) + _spotifyAuth = StateObject(wrappedValue: spotify) + _orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager, spotifyAuth: spotify)) } var body: some Scene { @@ -25,6 +28,12 @@ struct irisApp: App { ContentView() .environmentObject(ble) .environmentObject(orchestrator) + .environmentObject(spotifyAuth) + .onOpenURL { url in + if url.scheme == "iris-spotify-auth" { + spotifyAuth.handleCallback(url: url) + } + } .onChange(of: scenePhase) { phase in if phase == .active || phase == .background { ble.start()