iOS: section reorder and visibility, CarPlay downloads fix, stem playlist refresh fix

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-28 16:30:30 -07:00
1 parent 3e832e3125
commit 5b7fadb7f7
6 files changed
+121 -30

No files matched your search

+4
View File
@@ -12,3 +12,7 @@
2026-10-28 iOS: Downloads 3-dots menu gets View Queue (waiting, downloading, failed, done).
2026-10-28 iOS: Library screen can be reordered (Reorder button). Library Radio and Random Album Radio shuffle the whole library.
2026-10-28 iOS: Random Album Radio plays whole albums in track order, albums in random order across the library, loading the next album as one finishes.
2026-10-28 iOS: Library screen Reorder now covers whole sections (Recent Plays, Recently Added, On This Day, Stations, Browse) and each can be shown or hidden
2026-10-28 iOS: Removed drag reorder from the Tab Bar settings page
2026-10-28 iOS: CarPlay Downloads Playlists, Albums and Artists load from local downloads, work offline
2026-10-28 iOS: Stem playlist metadata refresh matches by key, file, then title/artist with a search fallback, and also runs when the app returns to the foreground
+29 -3
View File
@@ -77,6 +77,8 @@ final class CarLibrary: @unchecked Sendable {
guard let (kind, key) = CarIds.split(id) else { return nil }
switch kind {
case "dl": return try await downloadsView(key)
case "dlalbum": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
case "dlartist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: false)
case "playlist", "stemplaylist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true)
case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
case "artist": return try await artistPage(key, away)
@@ -102,14 +104,36 @@ final class CarLibrary: @unchecked Sendable {
private func downloadsView(_ key: String) async throws -> [CarItem]? {
switch key {
case "playlists": return try await playlists(true, onlyDownloaded: true)
case "albums": return try await albumsFor(true).map { albumItem($0) }
case "artists": return try await artistsFor(true).map { artistItem($0) }
case "playlists": return await downloadedPlaylists()
case "albums":
return albumsOf(downloadedTracks()).sorted { $0.title.lowercased() < $1.title.lowercased() }.map { a in
CarItem(id: "dlalbum:\(a.ratingKey)", title: a.title, subtitle: a.artist, art: artOr(a.thumb, a.title), playable: false)
}
case "artists":
return (try await artistsFor(true)).map { a in
CarItem(id: "dlartist:\(a.ratingKey)", title: a.title, art: artOr(a.thumb, a.sortTitle), playable: false)
}
case "songs": return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true)
default: return nil
}
}
/// Local only: stem playlists with a downloaded song, plus Plex playlists if the server answers.
private func downloadedPlaylists() async -> [CarItem] {
let have = Set(downloadedTracks().map(\.ratingKey))
let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { l in l.tracks.contains { have.contains($0.ratingKey) } } }
var out = stem.map { l in
CarItem(id: "stemplaylist:\(l.id)", title: l.title, subtitle: CarTree.countLabel(l.items.count, "song", "songs"),
art: artOr(l.thumb, l.title), playable: false, group: "Stem Playlists")
}
if let all = try? await plex.playlists() {
for p in all where ((try? await plex.playlistTracks(p.ratingKey))?.contains { have.contains($0.ratingKey) } == true) {
out.append(playlistItem(p, group: "Plex Playlists"))
}
}
return out
}
private func playlists(_ away: Bool, onlyDownloaded: Bool = false) async throws -> [CarItem] {
let all = try await plex.playlists()
var shown: [Playlist] = []
@@ -204,6 +228,8 @@ final class CarLibrary: @unchecked Sendable {
case "playlist": all = try await plex.playlistTracks(key)
case "stemplaylist": all = await MainActor.run { AppGraph.shared.stemPlaylists.list(key)?.tracks ?? [] }
case "album": all = try await plex.albumTracks(key)
case "dlalbum": all = downloadedTracks().filter { $0.albumKey == key }
case "dlartist": all = downloadedTracks().filter { $0.artistKey == key }
case "artist": all = away ? downloadedTracks().filter { $0.artistKey == key } : try await plex.artistTracks(key)
case "search": all = try await searchTracks(key, away)
default: all = []
+2
View File
@@ -226,6 +226,8 @@ final class Settings: @unchecked Sendable {
/// Downloads page order: 0 download order, 1 artist, 2 release date.
/// Library screen row order, titles joined by "|". Unlisted rows keep their default place after the listed ones.
var libraryOrder: String { get { read(\.libraryOrder, "libraryOrder", "") } set { write(\.libraryOrder, "libraryOrder", newValue, "") } }
/// Library screen sections the user switched off, titles joined by "|".
var libraryHidden: String { get { read(\.libraryHidden, "libraryHidden", "") } set { write(\.libraryHidden, "libraryHidden", newValue, "") } }
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } }
/// How far down Soft karaoke puts the vocals, in dB.
+18 -5
View File
@@ -59,22 +59,35 @@ final class StemPlaylists {
/// Keeps each item's uid and mix, and leaves songs the library can't find as they are.
/// Returns true when anything changed.
@discardableResult
func refresh(albumTracks: (String) async throws -> [Track]) async -> Bool {
func refresh(albumTracks: (String) async throws -> [Track],
search: ((String) async throws -> [Track])? = nil) async -> Bool {
var fresh: [String: [Track]] = [:]
for key in Set(lists.flatMap { $0.items.compactMap(\.track.albumKey) }) {
if let t = try? await albumTracks(key) { fresh[key] = t }
}
var changed = false
var found: [String: [Track]] = [:]
func norm(_ s: String) -> String { s.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil).trimmingCharacters(in: .whitespaces) }
func pick(_ old: Track, _ pool: [Track]) -> Track? {
pool.first { $0.ratingKey == old.ratingKey }
?? old.partFile.flatMap { f in pool.first { $0.partFile == f } }
?? pool.first { norm($0.title) == norm(old.title) && norm($0.artist) == norm(old.artist) && (old.album.isEmpty || norm($0.album) == norm(old.album)) }
?? pool.first { norm($0.title) == norm(old.title) && norm($0.artist) == norm(old.artist) }
}
var changed = false, missed = 0
for li in lists.indices {
for ii in lists[li].items.indices {
let old = lists[li].items[ii].track
guard let key = old.albumKey, let pool = fresh[key] else { continue }
guard var new = pool.first(where: { $0.ratingKey == old.ratingKey })
?? pool.first(where: { $0.title == old.title && $0.artist == old.artist }) else { continue }
var new = old.albumKey.flatMap { fresh[$0] }.flatMap { pick(old, $0) }
if new == nil, let search {
if found[old.title] == nil { found[old.title] = (try? await search(old.title)) ?? [] }
new = pick(old, found[old.title] ?? [])
}
guard var new else { missed += 1; continue }
if new.stemKey == nil { new.stemKey = old.stemKey }
if new != old { lists[li].items[ii].track = new; changed = true }
}
}
print("[stemplaylists] refresh: \(fresh.count) albums, changed=\(changed), unmatched=\(missed)")
if changed { save() }
return changed
}
+67 -17
View File
@@ -9,6 +9,7 @@ struct LibraryHome: View {
@State private var played: [Album] = []
@State private var added: [Track] = []
@State private var reload = 0
@Environment(\.scenePhase) private var scenePhase
@State private var editing = false
private var g: AppGraph { .shared }
@@ -28,22 +29,24 @@ struct LibraryHome: View {
g.settings.libraryOrder = (t + saved.filter { !t.contains($0) }).joined(separator: "|")
}
var body: some View {
let s = g.settings
let ready = s.libraryReady
private static let sectionNames = ["Recent Plays", "Recently Added", "On This Day", "Stations", "Browse"]
private var shownSections: [String] { ordered(Self.sectionNames) { $0 } }
private var hiddenSections: Set<String> { Set(g.settings.libraryHidden.split(separator: "|").map(String.init)) }
private func toggle(_ name: String) {
var h = hiddenSections
if h.contains(name) { h.remove(name) } else { h.insert(name) }
g.settings.libraryHidden = Self.sectionNames.filter(h.contains).joined(separator: "|")
}
@ViewBuilder private func section(_ name: String) -> some View {
let today = Date()
Page(title: "Library") {
if !ready {
EmptyState(icon: "server.rack", title: "Connect a Server",
message: "Add your Plex or Jellyfin server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
ui.push(.Servers)
}
}
if ready {
switch name {
case "Recent Plays":
if !played.isEmpty {
SectionTitle(title: "Recent Plays")
RecentShelf(albums: played)
}
case "Recently Added":
if !added.isEmpty {
SectionTitle(title: "Recently Added")
ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in
@@ -51,8 +54,7 @@ struct LibraryHome: View {
.listRow(inset: MARGIN + 60, line: i < added.count - 1)
}
}
}
if ready {
case "On This Day":
SectionTitle(title: "On This Day")
Text(today.formatted(.dateTime.month(.wide).day())).font(.subheadline).foregroundStyle(p.label2)
.padding(.leading, MARGIN).padding(.bottom, 10)
@@ -76,8 +78,7 @@ struct LibraryHome: View {
}
}
}
}
if ready {
case "Stations":
SectionTitle(title: "Stations")
let stations: [(String, String, () async throws -> Void)] = [
("dot.radiowaves.left.and.right", "Library Radio", {
@@ -91,7 +92,7 @@ struct LibraryHome: View {
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) }
}
}
default:
// Every tab except Settings (and this one).
SectionTitle(title: "Browse")
let rows: [(String, String, Route)] = [
@@ -105,6 +106,33 @@ struct LibraryHome: View {
move: { move(r.1, $0, in: shownRows.map(\.1)) }) { ui.push(r.2) }
}
}
}
var body: some View {
let s = g.settings
let ready = s.libraryReady
Page(title: "Library") {
if !ready {
EmptyState(icon: "server.rack", title: "Connect a Server",
message: "Add your Plex or Jellyfin server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
ui.push(.Servers)
}
}
if ready {
ForEach(shownSections, id: \.self) { name in
let off = hiddenSections.contains(name)
if editing {
SectionEditBar(title: name, off: off,
move: { move(name, $0, in: shownSections) },
toggle: { toggle(name) })
}
if !off || editing {
section(name).opacity(off ? 0.35 : 1).allowsHitTesting(!editing || !off)
}
}
}
}
.toolbar {
if ready {
ToolbarItem(placement: .topBarTrailing) {
@@ -112,6 +140,7 @@ struct LibraryHome: View {
}
}
}
.onChange(of: scenePhase) { _, ph in if ph == .active { reload += 1 } }
.task(id: "\(s.libraryKey)|\(reload)") {
guard ready else { return }
async let rp = try? g.plex.recentlyPlayed(count: 24)
@@ -119,7 +148,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() }
if await g.stemPlaylists.refresh(albumTracks: { try await g.plex.albumTracks($0) }, search: { try await g.plex.searchTracks($0) }) { await g.downloads.refresh() }
}
}
@@ -188,6 +217,27 @@ private struct RecentShelf: View {
}
}
/// Edit-mode header for a whole section: move it or switch it on/off.
private struct SectionEditBar: View {
let title: String
let off: Bool
let move: (Int) -> Void
let toggle: () -> Void
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 18) {
Text(title).font(.headline).foregroundStyle(p.label)
Spacer()
Image(systemName: off ? "eye.slash" : "eye").onTapGesture { toggle() }
Image(systemName: "chevron.up").onTapGesture { move(-1) }
Image(systemName: "chevron.down").onTapGesture { move(1) }
}
.font(.system(size: 17, weight: .semibold)).foregroundStyle(p.accent)
.padding(.horizontal, MARGIN).padding(.vertical, 10)
.background(p.label3.opacity(0.12))
}
}
private struct LibraryRow: View {
let icon: String
let title: String
+1 -5
View File
@@ -1005,17 +1005,13 @@ struct TabBarScreen: View {
.themedRow()
.deleteDisabled(t == .Settings)
}
.onMove { from, to in
shown.move(fromOffsets: from, toOffset: to)
commit()
}
.onDelete { idx in
let gone = Set(idx.map { shown[$0] }).subtracting([.Settings])
shown.removeAll { gone.contains($0) }
if shown.count < 2 { shown = Tab.defaultLayout }
commit()
}
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
} header: { Header(text: "In the bar") } footer: { Footer(text: "Swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
if !hidden.isEmpty {
Section {
ForEach(hidden, id: \.self) { t in