iOS: stem playlist refresh and covers, download queue page, reorderable Library, whole-library radio

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-28 15:49:09 -07:00
1 parent 7c8dfb54ca
commit a4c870251c
18 files changed
+514 -30

No files matched your search

+69
View File
@@ -19,6 +19,23 @@ private struct ServerInfo: Decodable {
}
}
/// What stemd is doing, from stemserve's /api/queue.
enum StemState: Equatable { case stemming, queued, failed }
private struct QueueInfo: Decodable {
var pending: [String] = []
var current: String?
var done = 0
var failed: [String] = []
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: AnyKey.self)
pending = c.list("pending")
current = c.str("current")
done = c.int("done") ?? 0
failed = c.list("failed")
}
}
/// Client for server/stemserve.py: the stems index, stem file URLs and waveforms.
@Observable
final class StemServer: @unchecked Sendable {
@@ -35,6 +52,13 @@ final class StemServer: @unchecked Sendable {
private(set) var status = StemServerStatus.unknown
/// When the index was last checked against the server, nil before the first check.
private(set) var checkedAt: Date?
/// stemd's live state: the song being separated, the ones waiting, the ones that failed.
private(set) var current: String?
private(set) var pending: Set<String> = []
private(set) var failed: Set<String> = []
@ObservationIgnored private var lastDone: Int?
var busy: Bool { current != nil || !pending.isEmpty }
init(http: Http, settings: Settings, dir: URL) {
self.http = http
@@ -53,6 +77,49 @@ final class StemServer: @unchecked Sendable {
try? await Task.sleep(nanoseconds: Self.checkSeconds * 1_000_000_000)
}
}
// Watch stemd's queue: every few seconds while it has work, slowly when idle.
Task.detached { [weak self] in
while let self {
await self.pollQueue()
try? await Task.sleep(nanoseconds: (self.busy ? 4 : 30) * 1_000_000_000)
}
}
}
/// Where a track stands with stemd, nil when it has stems or isn't known to it.
func state(_ track: Track) -> StemState? {
guard let f = track.partFile, !f.isEmpty else { return nil }
if find(track) != nil { return nil }
if let c = current, f == c || f.hasSuffix("/" + c) { return .stemming }
if pending.contains(f) { return .queued }
if failed.contains(f) { return .failed }
return nil
}
/// "Stemming “X” · N queued", nil when stemd is idle.
var liveLine: String? {
let n = pending.count
if let c = current {
let name = ((c as NSString).lastPathComponent as NSString).deletingPathExtension
return "Stemming “\(name)”" + (n > 0 ? " · \(n) queued" : "")
}
return n > 0 ? "\(n) queued for stems" : nil
}
/// Reads /api/queue. A change in the finished count pulls the new index straight away.
func pollQueue() async {
guard !base.isEmpty,
let u = try? url("/api/queue"),
let data = try? await http.data(req(u)),
let q = try? JSONDecoder().decode(QueueInfo.self, from: data) else { return }
let changed = lastDone != nil && lastDone != q.done
lastDone = q.done
await MainActor.run {
self.current = q.current
self.pending = Set(q.pending)
self.failed = Set(q.failed)
}
if changed { await refresh() }
}
private var base: String { settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
@@ -164,6 +231,8 @@ final class StemServer: @unchecked Sendable {
let msg = (try? JSONSerialization.jsonObject(with: body) as? [String: Any])?["error"] as? String
throw HttpError(code: code, message: code == 404 ? "stemserve needs updating for Stem Now" : msg ?? "stemserve answered \(code)")
}
await MainActor.run { paths.forEach { self.pending.insert($0) } }
Task.detached { [self] in await pollQueue() }
return paths.count
}