564 lines
21 KiB
Swift
564 lines
21 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 lastWinner: WinnerEnvelope? = nil
|
||
|
|
@Published private(set) var lastError: String? = nil
|
||
|
|
@Published private(set) var lastCandidates: [Candidate] = []
|
||
|
|
@Published private(set) var lastWeatherDiagnostics: [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(candidateId: 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 winner")
|
||
|
|
self?.pushLatestWinnerToBle()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
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.pushLatestWinnerToBle()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let feed = store.getFeed()
|
||
|
|
lastWinner = feed.asWinnerEnvelope()
|
||
|
|
}
|
||
|
|
|
||
|
|
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()
|
||
|
|
if let last = lastRecomputeAt, now.timeIntervalSince(last) > 15 * 60 {
|
||
|
|
return (true, "timer_15m")
|
||
|
|
}
|
||
|
|
if let lastLoc = lastRecomputeLocation {
|
||
|
|
let dist = location.distance(from: lastLoc)
|
||
|
|
if dist > 250 {
|
||
|
|
return (true, "moved_250m")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 {
|
||
|
|
if lastAcc - location.horizontalAccuracy > 50 {
|
||
|
|
return (true, "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) {
|
||
|
|
await self.weatherDataSource.candidatesWithDiagnostics(for: location, now: nowEpoch)
|
||
|
|
}
|
||
|
|
async let calendarResult = withTimeoutResult(seconds: 6) {
|
||
|
|
await self.calendarDataSource.candidatesWithDiagnostics(now: nowEpoch)
|
||
|
|
}
|
||
|
|
async let poiResult = withTimeoutResult(seconds: 6) {
|
||
|
|
try await self.poiDataSource.candidates(for: location, now: nowEpoch)
|
||
|
|
}
|
||
|
|
|
||
|
|
let wxRes = await weatherResult
|
||
|
|
let calRes = await calendarResult
|
||
|
|
let poiRes = await poiResult
|
||
|
|
|
||
|
|
var candidates: [Candidate] = []
|
||
|
|
var fetchFailed = false
|
||
|
|
var wxDiagnostics: [String: String] = [:]
|
||
|
|
var weatherNowCandidate: Candidate? = nil
|
||
|
|
|
||
|
|
switch wxRes {
|
||
|
|
case .success(let wx):
|
||
|
|
candidates.append(contentsOf: wx.candidates)
|
||
|
|
wxDiagnostics = wx.diagnostics
|
||
|
|
weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") })
|
||
|
|
if let wxErr = wx.weatherKitError {
|
||
|
|
fetchFailed = true
|
||
|
|
logger.warning("weather fetch error: \(wxErr, privacy: .public)")
|
||
|
|
}
|
||
|
|
case .failure(let error):
|
||
|
|
fetchFailed = true
|
||
|
|
logger.error("weather fetch failed: \(String(describing: error), privacy: .public)")
|
||
|
|
}
|
||
|
|
|
||
|
|
switch poiRes {
|
||
|
|
case .success(let pois):
|
||
|
|
candidates.append(contentsOf: pois)
|
||
|
|
case .failure(let error):
|
||
|
|
fetchFailed = true
|
||
|
|
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
|
||
|
|
}
|
||
|
|
|
||
|
|
switch calRes {
|
||
|
|
case .success(let cal):
|
||
|
|
candidates.append(contentsOf: cal.candidates)
|
||
|
|
if let err = cal.error {
|
||
|
|
fetchFailed = true
|
||
|
|
logger.warning("calendar error: \(err, privacy: .public)")
|
||
|
|
}
|
||
|
|
case .failure(let error):
|
||
|
|
fetchFailed = true
|
||
|
|
logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)")
|
||
|
|
}
|
||
|
|
|
||
|
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||
|
|
lastPipelineElapsedMs = elapsedMs
|
||
|
|
lastFetchFailed = fetchFailed
|
||
|
|
lastCandidates = candidates
|
||
|
|
lastWeatherDiagnostics = wxDiagnostics
|
||
|
|
|
||
|
|
logger.info("pipeline candidates total=\(candidates.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
||
|
|
|
||
|
|
if fetchFailed, candidates.isEmpty {
|
||
|
|
let fallbackFeed = store.getFeed(now: nowEpoch)
|
||
|
|
let fallbackWinner = fallbackFeed.asWinnerEnvelope()
|
||
|
|
lastWinner = fallbackWinner
|
||
|
|
lastError = "Fetch failed; using previous winner."
|
||
|
|
server.broadcastWinner(fallbackWinner)
|
||
|
|
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let unsuppressed = candidates
|
||
|
|
.filter { $0.type != .currentWeather }
|
||
|
|
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
||
|
|
let winner = ranker.pickWinner(from: unsuppressed, now: nowEpoch, context: userContext)
|
||
|
|
let envelope = WinnerEnvelope(schema: 1, generatedAt: nowEpoch, winner: winner, debug: nil)
|
||
|
|
let validated = (try? validateEnvelope(envelope)) ?? envelope
|
||
|
|
|
||
|
|
let feedEnvelope = FeedEnvelope.fromWinnerAndWeather(now: nowEpoch, winner: validated, weather: weatherNowCandidate)
|
||
|
|
store.setFeed(feedEnvelope, now: nowEpoch)
|
||
|
|
lastWinner = validated
|
||
|
|
lastRecomputeAt = Date()
|
||
|
|
lastRecomputeLocation = location
|
||
|
|
lastRecomputeAccuracy = location.horizontalAccuracy
|
||
|
|
lastError = fetchFailed ? "Partial fetch failure." : nil
|
||
|
|
|
||
|
|
logger.info("winner id=\(validated.winner.id, privacy: .public) type=\(validated.winner.type.rawValue, privacy: .public) prio=\(validated.winner.priority, format: .fixed(precision: 2)) ttl=\(validated.winner.ttlSec)")
|
||
|
|
|
||
|
|
server.broadcastWinner(validated)
|
||
|
|
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func pushLatestWinnerToBle() {
|
||
|
|
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")
|
||
|
|
pushLatestWinnerToBle()
|
||
|
|
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?.asFeedCard(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)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard {
|
||
|
|
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 FeedCard(
|
||
|
|
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,
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|