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>
231 lines
8.6 KiB
Swift
231 lines
8.6 KiB
Swift
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
|
|
}
|
|
}
|