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

+100 -10
View File
@@ -9,8 +9,25 @@ struct LibraryHome: View {
@State private var played: [Album] = []
@State private var added: [Track] = []
@State private var reload = 0
@State private var editing = false
private var g: AppGraph { .shared }
/// Rows in the saved order. Titles not saved yet keep their default order after the saved ones.
private func ordered<T>(_ items: [T], _ title: (T) -> String) -> [T] {
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
func rank(_ t: String) -> Int { saved.firstIndex(of: t) ?? saved.count }
return items.enumerated().sorted { (rank(title($0.element)), $0.offset) < (rank(title($1.element)), $1.offset) }.map(\.element)
}
/// Moves a row one place within its section and saves the whole order.
private func move(_ title: String, _ by: Int, in titles: [String]) {
guard let i = titles.firstIndex(of: title), titles.indices.contains(i + by) else { return }
var t = titles
t.swapAt(i, i + by)
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
g.settings.libraryOrder = (t + saved.filter { !t.contains($0) }).joined(separator: "|")
}
var body: some View {
let s = g.settings
let ready = s.libraryReady
@@ -63,15 +80,18 @@ struct LibraryHome: View {
if ready {
SectionTitle(title: "Stations")
let stations: [(String, String, () async throws -> [Track])] = [
("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.randomTracks(200) }),
("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.allTracksShuffled() }),
("square.stack", "Random Album Radio", {
// Whole albums in random order, across the entire library.
var out: [Track] = []
for a in try await g.plex.randomAlbums(10) { out += try await g.plex.albumTracks(a.ratingKey) }
for a in try await g.plex.albums().shuffled() { out += try await g.plex.albumTracks(a.ratingKey) }
return out
}),
]
ForEach(stations, id: \.1) { st in
LibraryRow(icon: st.0, title: st.1, last: st.1 == stations.last?.1) { play(st.2) }
let shown = ordered(stations) { $0.1 }
ForEach(shown, id: \.1) { st in
LibraryRow(icon: st.0, title: st.1, last: st.1 == shown.last?.1, editing: editing,
move: { move(st.1, $0, in: shown.map(\.1)) }) { play(st.2) }
}
}
// Every tab except Settings (and this one).
@@ -81,8 +101,17 @@ struct LibraryHome: View {
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ("guitars.fill", "Genres", .Genres),
("arrow.down.circle", "Downloads", .Downloads), ("antenna.radiowaves.left.and.right", "Web Radio", .WebRadio),
]
ForEach(rows, id: \.1) { r in
LibraryRow(icon: r.0, title: r.1, last: r.1 == rows.last?.1) { ui.push(r.2) }
let shownRows = ordered(rows) { $0.1 }
ForEach(shownRows, id: \.1) { r in
LibraryRow(icon: r.0, title: r.1, last: r.1 == shownRows.last?.1, editing: editing,
move: { move(r.1, $0, in: shownRows.map(\.1)) }) { ui.push(r.2) }
}
}
.toolbar {
if ready {
ToolbarItem(placement: .topBarTrailing) {
Button(editing ? "Done" : "Reorder") { withAnimation { editing.toggle() } }
}
}
}
.task(id: "\(s.libraryKey)|\(reload)") {
@@ -92,6 +121,7 @@ struct LibraryHome: View {
await LoadState.load($albums) { try await g.plex.albums() }
played = Array((await rp ?? []).prefix(24))
added = Array((await ra ?? []).prefix(10))
if await g.stemPlaylists.refresh(albumTracks: { try await g.plex.albumTracks($0) }) { await g.downloads.refresh() }
}
}
@@ -166,15 +196,25 @@ private struct LibraryRow: View {
let icon: String
let title: String
let last: Bool
var editing = false
var move: (Int) -> Void = { _ in }
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Button(action: { if !editing { action() } }) {
HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26)
Text(title).font(.title3).foregroundStyle(p.label)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
if editing {
HStack(spacing: 18) {
Image(systemName: "chevron.up").onTapGesture { move(-1) }
Image(systemName: "chevron.down").onTapGesture { move(1) }
}
.font(.system(size: 17, weight: .semibold)).foregroundStyle(p.accent)
} else {
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
}
}
.frame(height: 52)
.contentShape(Rectangle())
@@ -472,7 +512,7 @@ struct SongsScreen: View {
/// Shuffles the whole library, not just the pages loaded so far.
private func shuffleAll() {
Task {
do { g.playback.playTracks(try await g.plex.randomTracks(1000), shuffle: true) }
do { g.playback.playTracks(try await g.plex.allTracksShuffled(), shuffle: true) }
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") }
}
}
@@ -700,7 +740,7 @@ struct DownloadsScreen: View {
SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i }
})
}
MoreMenu(items: moreItems(all, pinned))
MoreMenu(items: moreItems(all, pinned) + [SheetAction("View Queue", icon: "list.bullet") { ui.push(.DownloadQueue) }])
}
}
}
@@ -813,3 +853,53 @@ struct GenreScreen: View {
}
}
}
struct DownloadQueueScreen: View {
private var g: AppGraph { .shared }
var body: some View {
let recs = g.downloads.records.values
let active = recs.filter { !$0.done && $0.error == nil }.sorted { $0.added < $1.added }
let failed = recs.filter { $0.error != nil }.sorted { $0.added < $1.added }
let done = recs.filter(\.done).sorted { $0.added > $1.added }
List {
if active.isEmpty && failed.isEmpty && done.isEmpty {
Text("Nothing in the queue").foregroundStyle(.secondary)
}
if !active.isEmpty {
Section("In Progress · \(active.count)") {
ForEach(active, id: \.track.ratingKey) { r in
let p = g.downloads.progress[r.track.ratingKey] ?? 0
row(r, status: p > 0 ? "Downloading \(Int(p * 100))%" : "Waiting", progress: p > 0 ? p : nil)
}
}
}
if !failed.isEmpty {
Section("Failed · \(failed.count)") {
ForEach(failed, id: \.track.ratingKey) { r in row(r, status: r.error ?? "Failed", progress: nil) }
}
}
if !done.isEmpty {
Section("Completed · \(done.count)") {
ForEach(done, id: \.track.ratingKey) { r in row(r, status: r.stems ? "Done · stems" : "Done", progress: nil) }
}
}
}
.navigationTitle("Download Queue")
.toolbar {
if !failed.isEmpty {
Button("Retry Failed") { g.downloads.retryFailed() }
}
}
.task { await g.downloads.refresh() }
}
private func row(_ r: DownloadRecord, status: String, progress: Float?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(r.track.title).lineLimit(1)
Text("\(r.track.artist) · \(status)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let progress { ProgressView(value: progress) }
}
}
}