SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
74 lines
3.1 KiB
Swift
74 lines
3.1 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// Whether Plex or stemserve answers right now. In the car the phone is usually on cellular
|
|
/// and the LAN addresses just time out, so the car UI asks this first (a quick probe,
|
|
/// remembered briefly) and goes straight to cached data when home is out of reach.
|
|
@Observable
|
|
final class HomeReach: @unchecked Sendable {
|
|
private static let ttl: UInt64 = 20_000_000_000
|
|
|
|
@ObservationIgnored private let settings: Settings
|
|
@ObservationIgnored private let session: URLSession
|
|
@ObservationIgnored private let lock = AsyncLock()
|
|
@ObservationIgnored private let stamp = UnfairLock()
|
|
@ObservationIgnored private var checkedAt: UInt64 = 0
|
|
@ObservationIgnored private var _last = true
|
|
@ObservationIgnored private var listeners: [() -> Void] = []
|
|
|
|
/// The last answer; true until a probe says otherwise. Changes as the phone joins or leaves home Wi-Fi.
|
|
private(set) var home = true
|
|
|
|
/// Readable from any thread (the car's Plex client asks it on every request).
|
|
var lastKnown: Bool { stamp.with { _last } }
|
|
|
|
init(settings: Settings, net: Connectivity) {
|
|
self.settings = settings
|
|
let c = URLSessionConfiguration.ephemeral
|
|
c.timeoutIntervalForRequest = 2
|
|
c.timeoutIntervalForResource = 2.5
|
|
c.waitsForConnectivity = false
|
|
session = URLSession(configuration: c)
|
|
// A new network (home Wi-Fi in range, or lost) invalidates the answer; ask again once it
|
|
// settles, so the car's lists can switch over without anyone browsing first.
|
|
net.onChange { [weak self] in
|
|
guard let self else { return }
|
|
self.stamp.with { self.checkedAt = 0 }
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
_ = await self.reachable()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Main-thread callback when [home] changes.
|
|
func onChange(_ f: @escaping () -> Void) { listeners.append(f) }
|
|
|
|
func reachable() async -> Bool {
|
|
await lock.run {
|
|
let now = DispatchTime.now().uptimeNanoseconds
|
|
let (at, last) = stamp.with { (checkedAt, _last) }
|
|
if at != 0 && now - at < Self.ttl { return last }
|
|
let plex = settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/identity"
|
|
let stems = settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info"
|
|
async let a = answers(plex)
|
|
async let b = answers(stems)
|
|
let (ra, rb) = await (a, b)
|
|
let ok = ra || rb
|
|
stamp.with { _last = ok; checkedAt = DispatchTime.now().uptimeNanoseconds }
|
|
await MainActor.run {
|
|
if home != ok {
|
|
home = ok
|
|
listeners.forEach { $0() }
|
|
}
|
|
}
|
|
return ok
|
|
}
|
|
}
|
|
|
|
private func answers(_ s: String) async -> Bool {
|
|
guard let url = URL(string: s), let (_, r) = try? await session.data(from: url) else { return false }
|
|
return ((r as? HTTPURLResponse)?.statusCode ?? 500) < 500
|
|
}
|
|
}
|