Files
aris-old/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift

842 lines
32 KiB
Swift
Raw Normal View History

2026-01-08 19:16:32 +00:00
//
// ContextOrchestrator.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
import MediaPlayer
import MusicKit
import os
@available(iOS 16.0, *)
@MainActor
final class ContextOrchestrator: NSObject, ObservableObject {
@Published private(set) var authorization: CLAuthorizationStatus = .notDetermined
@Published private(set) var lastLocation: CLLocation? = nil
@Published private(set) var lastRecomputeAt: Date? = nil
@Published private(set) var lastRecomputeReason: String? = nil
2026-01-10 00:25:36 +00:00
@Published private(set) var lastFeed: FeedEnvelope? = nil
2026-01-08 19:16:32 +00:00
@Published private(set) var lastError: String? = nil
@Published private(set) var lastWeatherDiagnostics: [String: String] = [:]
2026-01-10 00:25:36 +00:00
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
2026-01-08 19:16:32 +00:00
@Published private(set) var lastPipelineElapsedMs: Int? = nil
@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")
}
}
}
2026-01-08 19:16:32 +00:00
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator")
private let locationManager = CLLocationManager()
private let weatherDataSource = WeatherDataSource()
private let calendarDataSource = CalendarDataSource()
private let poiDataSource = POIDataSource()
private let ranker: HeuristicRanker
private let store: FeedStore
private let server: LocalServer
private let ble: BlePeripheralManager
private let nowPlayingMonitor = NowPlayingMonitor()
private let spotifyMonitor: SpotifyNowPlayingMonitor
private let spotifyAuth: SpotifyAuthManager
2026-01-08 19:16:32 +00:00
private var lastRecomputeLocation: CLLocation? = nil
private var lastRecomputeAccuracy: CLLocationAccuracy? = nil
private var recomputeInFlight = false
private var lastRecomputeAttemptAt: Date? = nil
init(store: FeedStore = FeedStore(),
server: LocalServer = LocalServer(),
ble: BlePeripheralManager,
spotifyAuth: SpotifyAuthManager) {
2026-01-08 19:16:32 +00:00
self.store = store
self.server = server
self.ble = ble
self.spotifyAuth = spotifyAuth
self.spotifyMonitor = SpotifyNowPlayingMonitor(authManager: spotifyAuth)
2026-01-10 00:25:36 +00:00
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
}
2026-01-08 19:16:32 +00:00
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.distanceFilter = kCLDistanceFilterNone
locationManager.pausesLocationUpdatesAutomatically = false
locationManager.allowsBackgroundLocationUpdates = true
ble.onFirstSubscribe = { [weak self] in
Task { @MainActor in
2026-01-10 00:25:36 +00:00
self?.logger.info("BLE subscribed: pushing latest feed")
self?.pushLatestFeedToBle()
2026-01-08 19:16:32 +00:00
}
}
ble.onControlCommand = { [weak self] command in
Task { @MainActor in
self?.handleBleControl(command)
}
}
nowPlayingMonitor.onUpdate = { [weak self] update in
Task { @MainActor in
guard let self, self.musicSource == .appleMusic else { return }
2026-01-08 19:16:32 +00:00
self.musicAuthorization = update.authorization
self.nowPlaying = update.snapshot
2026-01-10 00:25:36 +00:00
self.pushLatestFeedToBle()
2026-01-08 19:16:32 +00:00
}
}
spotifyMonitor.onUpdate = { [weak self] update in
Task { @MainActor in
guard let self, self.musicSource == .spotify else { return }
self.spotifyNowPlaying = update.snapshot
self.pushLatestFeedToBle()
}
}
2026-01-08 19:16:32 +00:00
let feed = store.getFeed()
2026-01-10 00:25:36 +00:00
lastFeed = feed
2026-01-08 19:16:32 +00:00
}
func start() {
authorization = locationManager.authorizationStatus
logger.info("start auth=\(String(describing: self.authorization), privacy: .public)")
server.start()
startMusicMonitor()
2026-01-08 19:16:32 +00:00
requestPermissionsIfNeeded()
locationManager.startUpdatingLocation()
}
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)")
2026-01-08 19:16:32 +00:00
}
func recomputeNow(reason: String = "manual") {
guard let location = lastLocation ?? locationManager.location else {
logger.info("recomputeNow skipped: no location")
return
}
maybeRecompute(for: location, reason: reason, force: true)
}
func sendFixtureFeedNow() {
guard let url = Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json", subdirectory: "ProtocolFixtures")
?? Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json"),
let data = try? Data(contentsOf: url) else {
logger.error("fixture feed missing in bundle")
return
}
ble.sendOpaque(data.trimmedTrailingWhitespace(), msgType: 1)
logger.info("sent fixture feed bytes=\(data.count)")
}
private func requestPermissionsIfNeeded() {
switch locationManager.authorizationStatus {
case .notDetermined:
logger.info("requestWhenInUseAuthorization")
locationManager.requestWhenInUseAuthorization()
case .authorizedWhenInUse:
logger.info("requestAlwaysAuthorization")
locationManager.requestAlwaysAuthorization()
case .authorizedAlways:
break
case .restricted, .denied:
lastError = "Location permission denied."
@unknown default:
break
}
}
private func maybeRecompute(for location: CLLocation, reason: String, force: Bool) {
let now = Date()
let hardThrottleSec: TimeInterval = 60
if !force, let lastAttempt = lastRecomputeAttemptAt, now.timeIntervalSince(lastAttempt) < hardThrottleSec {
logger.info("skip recompute (throttle) reason=\(reason, privacy: .public)")
return
}
lastRecomputeAttemptAt = now
if recomputeInFlight {
logger.info("skip recompute (in-flight) reason=\(reason, privacy: .public)")
return
}
recomputeInFlight = true
lastRecomputeReason = reason
logger.info("recompute start reason=\(reason, privacy: .public) lat=\(location.coordinate.latitude, format: .fixed(precision: 5)) lon=\(location.coordinate.longitude, format: .fixed(precision: 5)) acc=\(location.horizontalAccuracy, format: .fixed(precision: 1))")
Task {
await self.recomputePipeline(location: location, reason: reason)
}
}
private func shouldTriggerRecompute(for location: CLLocation) -> (Bool, String) {
if lastRecomputeAt == nil {
return (true, "initial")
}
let now = Date()
2026-01-10 18:50:15 +00:00
let speed = max(0, location.speed)
let moving = speed >= 1.0
let maxInterval: TimeInterval = moving ? 5 * 60 : 15 * 60
if let last = lastRecomputeAt, now.timeIntervalSince(last) > maxInterval {
return (true, moving ? "timer_5m_moving" : "timer_15m")
2026-01-08 19:16:32 +00:00
}
if let lastLoc = lastRecomputeLocation {
let dist = location.distance(from: lastLoc)
2026-01-10 18:50:15 +00:00
let distanceThreshold: CLLocationDistance = moving ? 100 : 250
if dist > distanceThreshold {
return (true, moving ? "moved_100m_moving" : "moved_250m")
2026-01-08 19:16:32 +00:00
}
}
if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 {
2026-01-10 18:50:15 +00:00
let improvementThreshold: CLLocationAccuracy = moving ? 30 : 50
if lastAcc - location.horizontalAccuracy > improvementThreshold {
return (true, moving ? "accuracy_improved_30m" : "accuracy_improved_50m")
2026-01-08 19:16:32 +00:00
}
}
return (false, "no_trigger")
}
private func recomputePipeline(location: CLLocation, reason: String) async {
defer {
Task { @MainActor in
self.recomputeInFlight = false
}
}
let nowEpoch = Int(Date().timeIntervalSince1970)
let userContext = UserContext(isMoving: location.speed >= 1.0, city: "London")
let start = Date()
async let weatherResult = withTimeoutResult(seconds: 6) {
2026-01-10 00:25:36 +00:00
try await self.weatherDataSource.dataWithDiagnostics(for: location, now: nowEpoch)
2026-01-08 19:16:32 +00:00
}
async let calendarResult = withTimeoutResult(seconds: 6) {
2026-01-10 00:25:36 +00:00
try await self.calendarDataSource.dataWithDiagnostics(now: nowEpoch)
2026-01-08 19:16:32 +00:00
}
async let poiResult = withTimeoutResult(seconds: 6) {
2026-01-10 00:25:36 +00:00
try await self.poiDataSource.data(for: location, now: nowEpoch)
2026-01-08 19:16:32 +00:00
}
let wxRes = await weatherResult
let calRes = await calendarResult
let poiRes = await poiResult
2026-01-10 00:25:36 +00:00
func calendarTTL(endAt: Int, now: Int) -> Int {
let ttl = endAt - now
return min(max(ttl, 60), 2 * 60 * 60)
}
func rainTitle(startAt: Int, now: Int) -> String {
let minutes = max(0, Int(((TimeInterval(startAt - now)) / 60.0).rounded()))
if minutes <= 0 { return "Rain now" }
return "Rain in ~\(minutes) min"
}
var rightNowCandidates: [HeuristicRanker.Ranked] = []
var calendarItems: [FeedItem] = []
2026-01-10 18:50:15 +00:00
var poiItems: [FeedItem] = []
2026-01-10 00:25:36 +00:00
var weatherNowItem: FeedItem? = nil
2026-01-08 19:16:32 +00:00
var fetchFailed = false
var wxDiagnostics: [String: String] = [:]
2026-01-10 00:25:36 +00:00
var calDiagnostics: [String: String] = [:]
2026-01-08 19:16:32 +00:00
switch wxRes {
2026-01-10 00:25:36 +00:00
case .success(let snapshot):
wxDiagnostics = snapshot.diagnostics
let weather = snapshot.data
if let current = weather.current {
weatherNowItem = FeedItem(
id: "wx:now:\(nowEpoch / 60)",
type: .currentWeather,
title: "Now \(current.temperatureC)°C \(current.condition.description)".truncated(maxLength: TextConstraints.titleMax),
subtitle: "Feels \(current.feelsLikeC)°C".truncated(maxLength: TextConstraints.subtitleMax),
priority: 0.8,
ttlSec: 1800,
condition: current.condition,
startsAt: nil,
bucket: .fyi,
actions: ["DISMISS"]
)
}
if let rainSoon = weather.rainSoon {
let title = rainTitle(startAt: rainSoon.startAt, now: nowEpoch).truncated(maxLength: TextConstraints.titleMax)
let subtitle = (rainSoon.source == .minutely ? "Carry an umbrella" : "Rain likely soon")
.truncated(maxLength: TextConstraints.subtitleMax)
let confidence: Double = (rainSoon.source == .minutely) ? 0.9 : 0.6
let item = FeedItem(
id: "wx:rain:\(rainSoon.startAt)",
type: .weatherAlert,
title: title,
subtitle: subtitle,
priority: confidence,
ttlSec: max(1, rainSoon.ttlSec),
condition: nil,
startsAt: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
}
if let wind = weather.windAlert {
let mph = Int((wind.gustMps * 2.236936).rounded())
let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax)
let subtitle = "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax)
let confidence: Double = 0.8
let item = FeedItem(
id: "wx:wind:\(nowEpoch):\(Int(wind.thresholdMps * 10))",
type: .weatherAlert,
title: title,
subtitle: subtitle,
priority: confidence,
ttlSec: max(1, wind.ttlSec),
condition: nil,
startsAt: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
}
for warning in weather.warnings {
let confidence = min(max(warning.confidence, 0.0), 1.0)
let item = FeedItem(
id: warning.id,
type: .weatherWarning,
title: warning.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: warning.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: confidence,
ttlSec: max(1, warning.ttlSec),
condition: nil,
startsAt: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
}
if let wxErr = wxDiagnostics["weatherkit_error"] {
2026-01-08 19:16:32 +00:00
fetchFailed = true
logger.warning("weather fetch error: \(wxErr, privacy: .public)")
}
case .failure(let error):
fetchFailed = true
2026-01-10 00:25:36 +00:00
if let weatherError = error as? WeatherDataSource.WeatherError,
case .weatherKitFailed(_, let diagnostics) = weatherError {
wxDiagnostics = diagnostics
logger.warning("weather fetch error: \(weatherError.localizedDescription, privacy: .public)")
} else {
logger.error("weather fetch failed: \(String(describing: error), privacy: .public)")
}
2026-01-08 19:16:32 +00:00
}
switch poiRes {
2026-01-10 18:50:15 +00:00
case .success(let pois):
if pois.isEmpty {
logger.info("no points of interests found")
}
2026-01-10 19:35:55 +00:00
// POIs are FYI-only; do not compete for the right-now winner.
2026-01-10 18:50:15 +00:00
for poi in pois.prefix(2) {
let subtitle = poiSubtitle(for: poi)
let confidence = min(max(poi.confidence, 0.0), 1.0)
let item = FeedItem(
id: poi.id,
type: .poiNearby,
title: poi.name.truncated(maxLength: TextConstraints.titleMax),
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: confidence,
ttlSec: max(1, poi.ttlSec),
condition: nil,
startsAt: nil,
poiType: poi.poiType,
bucket: .fyi,
actions: ["DISMISS"]
)
poiItems.append(item)
}
2026-01-08 19:16:32 +00:00
case .failure(let error):
fetchFailed = true
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
}
switch calRes {
2026-01-10 00:25:36 +00:00
case .success(let snapshot):
calDiagnostics = snapshot.diagnostics
for event in snapshot.data.events {
let isOngoing = event.startAt <= nowEpoch && event.endAt > nowEpoch
let startsInSec = event.startAt - nowEpoch
let eligibleForRightNow = isOngoing || startsInSec <= 30 * 60
let confidence: Double = isOngoing ? 0.9 : 0.7
let item = FeedItem(
id: event.id,
type: .calendarEvent,
title: event.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: "",
priority: confidence,
ttlSec: calendarTTL(endAt: event.endAt, now: nowEpoch),
condition: nil,
startsAt: event.startAt,
bucket: .fyi,
actions: ["DISMISS"]
)
calendarItems.append(item)
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: eligibleForRightNow))
2026-01-08 19:16:32 +00:00
}
case .failure(let error):
fetchFailed = true
2026-01-10 00:25:36 +00:00
if let calendarError = error as? CalendarDataSource.CalendarError {
calDiagnostics = calendarError.diagnostics
logger.warning("calendar error: \(calendarError.localizedDescription, privacy: .public)")
} else {
logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)")
}
2026-01-08 19:16:32 +00:00
}
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
lastPipelineElapsedMs = elapsedMs
lastFetchFailed = fetchFailed
lastWeatherDiagnostics = wxDiagnostics
2026-01-10 00:25:36 +00:00
lastCalendarDiagnostics = calDiagnostics
2026-01-08 19:16:32 +00:00
2026-01-10 18:50:15 +00:00
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
2026-01-08 19:16:32 +00:00
2026-01-10 00:25:36 +00:00
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
2026-01-08 19:16:32 +00:00
let fallbackFeed = store.getFeed(now: nowEpoch)
2026-01-10 00:25:36 +00:00
lastFeed = fallbackFeed
lastError = "Fetch failed; using previous feed."
server.broadcastFeed(fallbackFeed)
2026-01-08 19:16:32 +00:00
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1)
return
}
2026-01-10 19:35:55 +00:00
let poiCandidateCount = rightNowCandidates.filter { $0.item.type == .poiNearby }.count
if poiCandidateCount > 0 {
logger.warning("dropping poi candidates from right-now ranking count=\(poiCandidateCount)")
2026-01-10 00:25:36 +00:00
}
2026-01-10 19:35:55 +00:00
let eligibleUnsuppressed = rightNowCandidates
.filter { $0.item.type != .poiNearby }
.filter { ranked in
!store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch)
}
2026-01-10 00:25:36 +00:00
let winnerSelection = ranker.pickWinner(from: eligibleUnsuppressed, now: nowEpoch, context: userContext)
let winnerItem = winnerSelection?.item ?? FeedEnvelope.allQuiet(now: nowEpoch).feed[0]
let fyiCalendar = calendarItems
.filter { $0.id != winnerItem.id }
2026-01-08 19:16:32 +00:00
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
2026-01-10 00:25:36 +00:00
.sorted(by: { ($0.startsAt ?? Int.max) < ($1.startsAt ?? Int.max) })
.prefix(3)
var fyi: [FeedItem] = []
fyi.append(contentsOf: fyiCalendar.map { item in
FeedItem(
id: item.id,
type: item.type,
title: item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(item.priority, 0.0), 1.0),
ttlSec: max(1, item.ttlSec),
condition: item.condition,
startsAt: item.startsAt,
2026-01-10 18:50:15 +00:00
poiType: item.poiType,
bucket: .fyi,
actions: ["DISMISS"]
)
})
let fyiPOI = poiItems
.filter { $0.id != winnerItem.id }
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
.prefix(2)
fyi.append(contentsOf: fyiPOI.map { item in
FeedItem(
id: item.id,
type: item.type,
title: item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(item.priority, 0.0), 1.0),
ttlSec: max(1, item.ttlSec),
condition: item.condition,
startsAt: item.startsAt,
poiType: item.poiType,
2026-01-10 00:25:36 +00:00
bucket: .fyi,
actions: ["DISMISS"]
)
})
if let weatherNowItem,
weatherNowItem.id != winnerItem.id,
!store.isSuppressed(id: weatherNowItem.id, type: weatherNowItem.type, now: nowEpoch) {
fyi.append(weatherNowItem)
}
2026-01-08 19:16:32 +00:00
2026-01-10 00:25:36 +00:00
let items = [winnerItem] + fyi
let feedEnvelope = FeedEnvelope(
schema: 1,
generatedAt: nowEpoch,
feed: items,
meta: FeedMeta(winnerId: winnerItem.id, unreadCount: items.count)
)
2026-01-08 19:16:32 +00:00
store.setFeed(feedEnvelope, now: nowEpoch)
2026-01-10 00:25:36 +00:00
lastFeed = feedEnvelope
2026-01-08 19:16:32 +00:00
lastRecomputeAt = Date()
lastRecomputeLocation = location
lastRecomputeAccuracy = location.horizontalAccuracy
lastError = fetchFailed ? "Partial fetch failure." : nil
2026-01-10 00:25:36 +00:00
logger.info("winner id=\(winnerItem.id, privacy: .public) type=\(winnerItem.type.rawValue, privacy: .public) prio=\(winnerItem.priority, format: .fixed(precision: 2)) ttl=\(winnerItem.ttlSec)")
2026-01-08 19:16:32 +00:00
2026-01-10 00:25:36 +00:00
server.broadcastFeed(feedEnvelope)
2026-01-08 19:16:32 +00:00
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1)
}
2026-01-10 00:25:36 +00:00
private func pushLatestFeedToBle() {
2026-01-08 19:16:32 +00:00
let nowEpoch = Int(Date().timeIntervalSince1970)
let feed = store.getFeed(now: nowEpoch)
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1)
}
private func handleBleControl(_ command: String) {
guard !command.isEmpty else { return }
if command == "REQ_FULL" {
logger.info("BLE control REQ_FULL")
2026-01-10 00:25:36 +00:00
pushLatestFeedToBle()
2026-01-08 19:16:32 +00:00
return
}
if command.hasPrefix("ACK:") {
logger.info("BLE control \(command, privacy: .public)")
return
}
logger.info("BLE control unknown=\(command, privacy: .public)")
}
private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope {
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 {
2026-01-08 19:16:32 +00:00
return base
}
var cards = base.feed.filter { $0.type != .nowPlaying }
// Append after existing FYI cards (e.g. weather).
cards.append(nowPlayingCard)
return FeedEnvelope(
schema: base.schema,
generatedAt: base.generatedAt,
feed: cards,
meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count)
)
}
2026-01-10 18:50:15 +00:00
private func poiSubtitle(for poi: POIDataSource.POI) -> String {
let distance = distanceText(meters: poi.distanceMeters)
let walk = "\(poi.walkingMinutes) min walk"
let parts = [poi.category, distance, walk]
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return parts.joined(separator: "")
}
private func distanceText(meters: CLLocationDistance) -> String {
if meters >= 1000 {
return String(format: "%.1f km", meters / 1000.0)
}
return "\(Int(meters.rounded())) m"
}
2026-01-08 19:16:32 +00:00
}
extension ContextOrchestrator: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
authorization = manager.authorizationStatus
logger.info("auth changed=\(String(describing: self.authorization), privacy: .public)")
requestPermissionsIfNeeded()
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
logger.error("location error: \(String(describing: error), privacy: .public)")
lastError = "Location error: \(error)"
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let best = locations.sorted(by: { $0.horizontalAccuracy < $1.horizontalAccuracy }).first else { return }
lastLocation = best
let (should, reason) = shouldTriggerRecompute(for: best)
if should {
maybeRecompute(for: best, reason: reason, force: false)
}
}
}
enum TimeoutError: Error {
case timedOut
}
func withTimeoutResult<T>(seconds: Double, operation: @escaping () async throws -> T) async -> Result<T, Error> {
do {
let value = try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError.timedOut
}
let result = try await group.next()!
group.cancelAll()
return result
}
return .success(value)
} catch {
return .failure(error)
}
}
@available(iOS 16.0, *)
struct NowPlayingSnapshot: Equatable, Sendable {
let itemId: String
let title: String
let artist: String?
let album: String?
let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus
2026-01-10 00:25:36 +00:00
func asFeedItem(baseGeneratedAt: Int, now: Int) -> FeedItem {
2026-01-08 19:16:32 +00:00
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 ? "Apple Music" : subtitleParts.joined(separator: "")
2026-01-10 00:25:36 +00:00
return FeedItem(
2026-01-08 19:16:32 +00:00
id: "music:now:\(itemId)",
type: .nowPlaying,
title: title.truncated(maxLength: TextConstraints.titleMax),
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: playbackStatus == .playing ? 0.35 : 0.2,
ttlSec: ttl,
condition: nil,
2026-01-10 00:25:36 +00:00
startsAt: nil,
2026-01-08 19:16:32 +00:00
bucket: .fyi,
actions: ["DISMISS"]
)
}
}
@available(iOS 16.0, *)
@MainActor
final class NowPlayingMonitor {
struct Update: Sendable {
let authorization: MusicAuthorization.Status
let snapshot: NowPlayingSnapshot?
}
var onUpdate: ((Update) -> Void)? = nil
private let player = SystemMusicPlayer.shared
private let mpController = MPMusicPlayerController.systemMusicPlayer
private var observers: [NSObjectProtocol] = []
private var pollTimer: DispatchSourceTimer?
private var isRunning = false
private var authorization: MusicAuthorization.Status = .notDetermined
private var lastSnapshot: NowPlayingSnapshot? = nil
func start() {
guard !isRunning else { return }
isRunning = true
mpController.beginGeneratingPlaybackNotifications()
observers.append(
NotificationCenter.default.addObserver(
forName: .MPMusicPlayerControllerNowPlayingItemDidChange,
object: mpController,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.refresh(reason: "mp_now_playing_changed") }
}
)
observers.append(
NotificationCenter.default.addObserver(
forName: .MPMusicPlayerControllerPlaybackStateDidChange,
object: mpController,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.refresh(reason: "mp_playback_state_changed") }
}
)
startPolling()
Task { @MainActor in
await ensureAuthorization()
refresh(reason: "start")
}
}
func stop() {
guard isRunning else { return }
isRunning = false
pollTimer?.cancel()
pollTimer = nil
for token in observers {
NotificationCenter.default.removeObserver(token)
}
observers.removeAll()
mpController.endGeneratingPlaybackNotifications()
}
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + 2, repeating: 2)
timer.setEventHandler { [weak self] in
guard let self else { return }
self.refresh(reason: "poll")
}
timer.resume()
pollTimer = timer
}
private func ensureAuthorization() async {
if authorization == .notDetermined {
authorization = await MusicAuthorization.request()
onUpdate?(Update(authorization: authorization, snapshot: lastSnapshot))
}
}
private func refresh(reason: String) {
guard authorization == .authorized else {
if lastSnapshot != nil {
lastSnapshot = nil
onUpdate?(Update(authorization: authorization, snapshot: nil))
}
return
}
let playback = player.state.playbackStatus
guard playback != .stopped else {
if lastSnapshot != nil {
lastSnapshot = nil
onUpdate?(Update(authorization: authorization, snapshot: nil))
}
return
}
let mpItem = mpController.nowPlayingItem
let musicKitItem = player.queue.currentEntry?.item
let itemId = sanitizeId(
musicKitItem.map { String(describing: $0.id) }
?? mpItem.map { "mp:\($0.persistentID)" }
?? UUID().uuidString
)
let musicKitTitle = musicKitItem.map(nowPlayingTitle(from:))
let title = normalizeTitle(musicKitTitle) ?? normalizeTitle(mpItem?.title) ?? "Now Playing"
let artist = normalizePart(musicKitItem.flatMap(nowPlayingArtist(from:))) ?? normalizePart(mpItem?.artist)
let album = normalizePart(musicKitItem.flatMap(nowPlayingAlbum(from:))) ?? normalizePart(mpItem?.albumTitle)
let snapshot = NowPlayingSnapshot(itemId: itemId, title: title, artist: artist, album: album, playbackStatus: playback)
guard snapshot != lastSnapshot else { return }
lastSnapshot = snapshot
onUpdate?(Update(authorization: authorization, snapshot: snapshot))
}
private func nowPlayingTitle(from item: MusicItem) -> String {
if let song = item as? Song { return song.title }
if let album = item as? Album { return album.title }
if let playlist = item as? Playlist { return playlist.name }
return "Now Playing"
}
private func nowPlayingArtist(from item: MusicItem) -> String? {
if let song = item as? Song { return song.artistName }
if let album = item as? Album { return album.artistName }
return nil
}
private func nowPlayingAlbum(from item: MusicItem) -> String? {
if let song = item as? Song { return song.albumTitle }
return nil
}
private func sanitizeId(_ raw: String) -> String {
raw
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "\n", with: "_")
.replacingOccurrences(of: "\t", with: "_")
}
private func normalizePart(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func normalizeTitle(_ raw: String?) -> String? {
guard let value = normalizePart(raw) else { return nil }
if value == "Now Playing" { return nil }
return value
}
}