Add Spotify integration with toggleable music source

- Add OAuth 2.0 PKCE authentication for Spotify Web API
- Create SpotifyNowPlayingMonitor for polling current track
- Add Settings tab with music source toggle (Apple Music/Spotify)
- Store tokens securely in Keychain
- Display current track on Glass as NOW_PLAYING card

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 20:27:37 +00:00
parent c13a4f3247
commit 11ee893367
10 changed files with 875 additions and 6 deletions

View File

@@ -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
}