Add iOS app with CarPlay and Siri

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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 07:15:47 -07:00
1 parent 45bc0eefe0
commit fdf9e970b2
45 files changed
+9822

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
import Foundation
import Observation
#if canImport(UIKit) && !SWIFT_PACKAGE
import UIKit
#endif
struct DownloadRecord: Codable, Equatable {
var track: Track
var keys: [String] = []
var stems = false
var done = false
var error: String?
var added = Int64(Date().timeIntervalSince1970 * 1000)
init(track: Track) { self.track = track }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
track = try c.decode(Track.self, forKey: AnyKey("track"))
keys = (try? c.decode([String].self, forKey: AnyKey("keys"))) ?? []
stems = c.bool("stems") ?? false
done = c.bool("done") ?? false
error = c.str("error")
added = c.int64("added") ?? 0
}
}
/// Offline copies: the track's stems (or the original file) fully fetched into
/// [MediaCache] and pinned, plus the metadata needed to browse them with no server.
@Observable
final class Downloads: @unchecked Sendable {
@ObservationIgnored private let settings: Settings
@ObservationIgnored private let plex: PlexClient
@ObservationIgnored private let stems: StemServer
@ObservationIgnored private let cache: MediaCache
@ObservationIgnored private let net: Connectivity
@ObservationIgnored private let file: URL
@ObservationIgnored private let lock = NSCondition()
@ObservationIgnored private var state: [String: DownloadRecord] = [:]
@ObservationIgnored private var prog: [String: Float] = [:]
@ObservationIgnored private var queue: [String] = []
@ObservationIgnored private var cancelled = Set<String>()
@ObservationIgnored private let writeQueue = DispatchQueue(label: "downloads-save", qos: .utility)
#if canImport(UIKit) && !SWIFT_PACKAGE
@ObservationIgnored private var bgTask = UIBackgroundTaskIdentifier.invalid
#endif
static let waveBuckets = 256
private(set) var records: [String: DownloadRecord] = [:]
/// ratingKey -> 0..1 for queued and running downloads.
private(set) var progress: [String: Float] = [:]
init(settings: Settings, plex: PlexClient, stems: StemServer, cache: MediaCache, net: Connectivity, dir: URL) {
self.settings = settings
self.plex = plex
self.stems = stems
self.cache = cache
self.net = net
file = dir.appendingPathComponent("downloads.json")
if let data = try? Data(contentsOf: file), let list = try? JSONDecoder().decode([Lenient<DownloadRecord>].self, from: data) {
for r in list.compactMap(\.value) { state[r.track.ratingKey] = r }
}
records = state
for _ in 0..<2 {
let t = Thread { [weak self] in self?.work() }
t.qualityOfService = .utility
t.start()
}
// Resume anything interrupted last time.
state.values.filter { !$0.done && $0.error == nil }.forEach { enqueue($0.track.ratingKey) }
}
func isDownloaded(_ ratingKey: String) -> Bool { records[ratingKey]?.done == true }
func download(_ tracks: [Track]) {
lock.lock()
let add = tracks.filter { t in
guard let r = state[t.ratingKey] else { return true }
return !r.done && r.error != nil
}
lock.unlock()
if add.isEmpty { return }
update { m in for t in add { m[t.ratingKey] = DownloadRecord(track: t) } }
add.forEach { enqueue($0.ratingKey) }
}
func remove(_ tracks: [Track]) {
let keys = Set(tracks.map(\.ratingKey))
lock.lock()
cancelled.formUnion(keys)
let gone = state.filter { keys.contains($0.key) }.values
queue.removeAll { keys.contains($0) }
for k in keys { prog[k] = nil }
lock.unlock()
update { m in for k in keys { m[k] = nil } }
publish()
DispatchQueue.global(qos: .utility).async { [cache] in gone.forEach { $0.keys.forEach(cache.unpin) } }
}
func retryFailed() {
lock.lock()
let failed = state.values.filter { $0.error != nil }
lock.unlock()
update { m in for r in failed { m[r.track.ratingKey]?.error = nil } }
failed.forEach { enqueue($0.track.ratingKey) }
}
private func enqueue(_ key: String) {
lock.lock()
cancelled.remove(key)
prog[key] = 0
if !queue.contains(key) { queue.append(key) }
lock.broadcast()
lock.unlock()
publish()
}
private func isCancelled(_ key: String) -> Bool {
lock.lock(); defer { lock.unlock() }
return cancelled.contains(key)
}
private func work() {
while true {
lock.lock()
while queue.isEmpty { lock.wait() }
let key = queue.removeFirst()
lock.unlock()
run(key)
}
}
private func setProgress(_ key: String, _ f: Float?) {
lock.lock()
if f == nil || prog[key] != nil { prog[key] = f }
lock.unlock()
publish()
}
private func run(_ key: String) {
lock.lock()
let rec = state[key]
lock.unlock()
guard let rec, !rec.done else { setProgress(key, nil); return }
defer { setProgress(key, nil) }
do {
while settings.downloadsWifiOnly && !net.unmetered {
if isCancelled(key) { return }
Thread.sleep(forTimeInterval: 2)
}
let t = rec.track
let entry = stems.find(t)
let sources: [(String, URL?)]
if let entry {
let q = stems.effectiveQuality(settings.stemQuality)
stems.prepare(entry, q)
sources = entry.stems.map { (PlaybackManager.stemKey(entry, $0.file, $0.size, q), stems.fileUrl(entry, $0, q)) }
} else {
guard let part = t.partKey else { throw PlainError("No file on the server") }
sources = [(PlaybackManager.plexKey(t), plex.partUrl(part))]
}
update { m in
m[key]?.keys = sources.map(\.0)
m[key]?.stems = entry != nil
}
for (i, (cacheKey, url)) in sources.enumerated() {
let res = cache.acquire(cacheKey) { url }
defer { cache.release(res) }
res.pinned = true
var lastPublish = Date.distantPast
try res.downloadAll(progress: { got, len in
let f: Float = len > 0 ? Float(got) / Float(len) : 0
if Date().timeIntervalSince(lastPublish) > 0.25 {
lastPublish = Date()
self.setProgress(key, (Float(i) + f) / Float(sources.count))
}
}, cancelled: { self.isCancelled(key) })
}
if isCancelled(key) { return }
if let entry { Task { _ = await stems.peaks(entry, buckets: Self.waveBuckets) } } // warms the HTTP cache for offline
if let art = plex.artUrl(t.thumb, px: 600) { ArtLoader.shared.prefetch(art) }
update { m in
m[key]?.done = true
m[key]?.error = nil
}
} catch {
if isCancelled(key) { return }
update { m in m[key]?.error = error.localizedDescription.isEmpty ? "Download failed" : error.localizedDescription }
}
}
private func update(_ f: (inout [String: DownloadRecord]) -> Void) {
lock.lock()
f(&state)
let snapshot = state
lock.unlock()
DispatchQueue.main.async { self.records = snapshot }
writeQueue.async { [file] in
let list = snapshot.values.sorted { $0.added < $1.added }
if let data = try? JSONEncoder().encode(list) { try? data.write(to: file, options: .atomic) }
}
}
private func publish() {
lock.lock()
let p = prog
lock.unlock()
DispatchQueue.main.async {
if self.progress != p { self.progress = p }
self.holdBackground(!p.isEmpty)
}
}
/// iOS has no foreground service; ask for background time while downloads run. Playback
/// keeps the app alive anyway, and anything cut off resumes on next launch.
private func holdBackground(_ hold: Bool) {
#if canImport(UIKit) && !SWIFT_PACKAGE
if hold && bgTask == .invalid {
bgTask = UIApplication.shared.beginBackgroundTask(withName: "downloads") { [weak self] in
guard let self else { return }
UIApplication.shared.endBackgroundTask(self.bgTask)
self.bgTask = .invalid
}
} else if !hold && bgTask != .invalid {
UIApplication.shared.endBackgroundTask(bgTask)
bgTask = .invalid
}
#endif
}
}