From 5b7fadb7f713457aa9882292793dcb086051c0ef Mon Sep 17 00:00:00 2001 From: root Date: Wed, 28 Oct 2026 16:30:30 -0700 Subject: [PATCH] iOS: section reorder and visibility, CarPlay downloads fix, stem playlist refresh fix Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 + ios/Stemify/CarPlay/CarLibrary.swift | 32 +++++++- ios/Stemify/Core/Settings.swift | 2 + ios/Stemify/Playback/StemPlaylists.swift | 23 ++++-- ios/Stemify/UI/Screens/LibraryScreens.swift | 86 ++++++++++++++++---- ios/Stemify/UI/Screens/SettingsScreens.swift | 6 +- 6 files changed, 122 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d697ff1..d27fbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ios/Stemify/CarPlay/CarLibrary.swift b/ios/Stemify/CarPlay/CarLibrary.swift index c98c776..97054f8 100644 --- a/ios/Stemify/CarPlay/CarLibrary.swift +++ b/ios/Stemify/CarPlay/CarLibrary.swift @@ -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 = [] diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index 4bafab4..fda7996 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -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. diff --git a/ios/Stemify/Playback/StemPlaylists.swift b/ios/Stemify/Playback/StemPlaylists.swift index ecd48bc..79939d1 100644 --- a/ios/Stemify/Playback/StemPlaylists.swift +++ b/ios/Stemify/Playback/StemPlaylists.swift @@ -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 } diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index 90346c0..668658e 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -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 { 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 { - if !played.isEmpty { + 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 diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index 7a2e5fd..38704fe 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -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