785 lines
30 KiB
Swift
785 lines
30 KiB
Swift
//
|
|
// 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
|
|
@Published private(set) var lastFeed: FeedEnvelope? = nil
|
|
@Published private(set) var lastError: String? = nil
|
|
@Published private(set) var lastWeatherDiagnostics: [String: String] = [:]
|
|
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
|
|
@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
|
|
|
|
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 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) {
|
|
self.store = store
|
|
self.server = server
|
|
self.ble = ble
|
|
self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(feedItemId: id) })
|
|
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
|
|
self?.logger.info("BLE subscribed: pushing latest feed")
|
|
self?.pushLatestFeedToBle()
|
|
}
|
|
}
|
|
ble.onControlCommand = { [weak self] command in
|
|
Task { @MainActor in
|
|
self?.handleBleControl(command)
|
|
}
|
|
}
|
|
|
|
nowPlayingMonitor.onUpdate = { [weak self] update in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.musicAuthorization = update.authorization
|
|
self.nowPlaying = update.snapshot
|
|
self.pushLatestFeedToBle()
|
|
}
|
|
}
|
|
|
|
let feed = store.getFeed()
|
|
lastFeed = feed
|
|
}
|
|
|
|
func start() {
|
|
authorization = locationManager.authorizationStatus
|
|
logger.info("start auth=\(String(describing: self.authorization), privacy: .public)")
|
|
server.start()
|
|
nowPlayingMonitor.start()
|
|
requestPermissionsIfNeeded()
|
|
locationManager.startUpdatingLocation()
|
|
}
|
|
|
|
func stop() {
|
|
locationManager.stopUpdatingLocation()
|
|
nowPlayingMonitor.stop()
|
|
}
|
|
|
|
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()
|
|
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")
|
|
}
|
|
if let lastLoc = lastRecomputeLocation {
|
|
let dist = location.distance(from: lastLoc)
|
|
let distanceThreshold: CLLocationDistance = moving ? 100 : 250
|
|
if dist > distanceThreshold {
|
|
return (true, moving ? "moved_100m_moving" : "moved_250m")
|
|
}
|
|
}
|
|
if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 {
|
|
let improvementThreshold: CLLocationAccuracy = moving ? 30 : 50
|
|
if lastAcc - location.horizontalAccuracy > improvementThreshold {
|
|
return (true, moving ? "accuracy_improved_30m" : "accuracy_improved_50m")
|
|
}
|
|
}
|
|
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) {
|
|
try await self.weatherDataSource.dataWithDiagnostics(for: location, now: nowEpoch)
|
|
}
|
|
async let calendarResult = withTimeoutResult(seconds: 6) {
|
|
try await self.calendarDataSource.dataWithDiagnostics(now: nowEpoch)
|
|
}
|
|
async let poiResult = withTimeoutResult(seconds: 6) {
|
|
try await self.poiDataSource.data(for: location, now: nowEpoch)
|
|
}
|
|
|
|
let wxRes = await weatherResult
|
|
let calRes = await calendarResult
|
|
let poiRes = await poiResult
|
|
|
|
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] = []
|
|
var poiItems: [FeedItem] = []
|
|
var weatherNowItem: FeedItem? = nil
|
|
var fetchFailed = false
|
|
var wxDiagnostics: [String: String] = [:]
|
|
var calDiagnostics: [String: String] = [:]
|
|
|
|
switch wxRes {
|
|
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"] {
|
|
fetchFailed = true
|
|
logger.warning("weather fetch error: \(wxErr, privacy: .public)")
|
|
}
|
|
case .failure(let error):
|
|
fetchFailed = true
|
|
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)")
|
|
}
|
|
}
|
|
|
|
switch poiRes {
|
|
case .success(let pois):
|
|
if pois.isEmpty {
|
|
logger.info("no points of interests found")
|
|
}
|
|
// POIs are FYI-only; do not compete for the right-now winner.
|
|
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)
|
|
}
|
|
case .failure(let error):
|
|
fetchFailed = true
|
|
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
|
|
}
|
|
|
|
switch calRes {
|
|
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))
|
|
}
|
|
case .failure(let error):
|
|
fetchFailed = true
|
|
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)")
|
|
}
|
|
}
|
|
|
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
|
lastPipelineElapsedMs = elapsedMs
|
|
lastFetchFailed = fetchFailed
|
|
lastWeatherDiagnostics = wxDiagnostics
|
|
lastCalendarDiagnostics = calDiagnostics
|
|
|
|
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
|
|
|
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
|
let fallbackFeed = store.getFeed(now: nowEpoch)
|
|
lastFeed = fallbackFeed
|
|
lastError = "Fetch failed; using previous feed."
|
|
server.broadcastFeed(fallbackFeed)
|
|
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1)
|
|
return
|
|
}
|
|
|
|
let poiCandidateCount = rightNowCandidates.filter { $0.item.type == .poiNearby }.count
|
|
if poiCandidateCount > 0 {
|
|
logger.warning("dropping poi candidates from right-now ranking count=\(poiCandidateCount)")
|
|
}
|
|
|
|
let eligibleUnsuppressed = rightNowCandidates
|
|
.filter { $0.item.type != .poiNearby }
|
|
.filter { ranked in
|
|
!store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch)
|
|
}
|
|
|
|
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 }
|
|
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
|
.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,
|
|
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,
|
|
bucket: .fyi,
|
|
actions: ["DISMISS"]
|
|
)
|
|
})
|
|
|
|
if let weatherNowItem,
|
|
weatherNowItem.id != winnerItem.id,
|
|
!store.isSuppressed(id: weatherNowItem.id, type: weatherNowItem.type, now: nowEpoch) {
|
|
fyi.append(weatherNowItem)
|
|
}
|
|
|
|
let items = [winnerItem] + fyi
|
|
let feedEnvelope = FeedEnvelope(
|
|
schema: 1,
|
|
generatedAt: nowEpoch,
|
|
feed: items,
|
|
meta: FeedMeta(winnerId: winnerItem.id, unreadCount: items.count)
|
|
)
|
|
store.setFeed(feedEnvelope, now: nowEpoch)
|
|
lastFeed = feedEnvelope
|
|
lastRecomputeAt = Date()
|
|
lastRecomputeLocation = location
|
|
lastRecomputeAccuracy = location.horizontalAccuracy
|
|
lastError = fetchFailed ? "Partial fetch failure." : nil
|
|
|
|
logger.info("winner id=\(winnerItem.id, privacy: .public) type=\(winnerItem.type.rawValue, privacy: .public) prio=\(winnerItem.priority, format: .fixed(precision: 2)) ttl=\(winnerItem.ttlSec)")
|
|
|
|
server.broadcastFeed(feedEnvelope)
|
|
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1)
|
|
}
|
|
|
|
private func pushLatestFeedToBle() {
|
|
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")
|
|
pushLatestFeedToBle()
|
|
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 {
|
|
guard let nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) else {
|
|
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)
|
|
)
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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 ? "Apple Music" : subtitleParts.joined(separator: " • ")
|
|
|
|
return FeedItem(
|
|
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,
|
|
startsAt: nil,
|
|
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
|
|
}
|
|
}
|