ios: sort artists, albums, songs and playlists (1.0.5)

Plexamp-style sort menu on each library list: pick a field, pick it again
to flip direction. Choice is saved per screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 09:23:38 -07:00
1 parent d149af336a
commit 65bc69a7fe
4 files changed
+155 -27

No files matched your search

+7 -7
View File
@@ -113,8 +113,8 @@ final class PlexClient: @unchecked Sendable {
// ---- library ------------------------------------------------------------------
func artists() async throws -> [Artist] {
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", "titleSort")]).map { $0.toArtist() }
func artists(sort: String = "titleSort") async throws -> [Artist] {
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() }
}
func albums(sort: String = "titleSort") async throws -> [Album] {
@@ -126,9 +126,9 @@ final class PlexClient: @unchecked Sendable {
.metadata.map { $0.toAlbum() }
}
/// One page of every track, sorted by title. Returns the page and the library's track count.
func tracksPage(start: Int, size: Int) async throws -> ([Track], Int) {
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "titleSort")], start: start, size: size)
/// One page of every track. Returns the page and the library's track count.
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size)
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
}
@@ -190,8 +190,8 @@ final class PlexClient: @unchecked Sendable {
// ---- playlists ----------------------------------------------------------------
func playlists() async throws -> [Playlist] {
try await get("/playlists", [("playlistType", "audio")]).metadata.map { $0.toPlaylist() }
func playlists(sort: String = "titleSort") async throws -> [Playlist] {
try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
}
func playlistTracks(_ key: String) async throws -> [Track] {
+142 -16
View File
@@ -111,9 +111,131 @@ private struct LibraryRow: View {
// ---- artists --------------------------------------------------------------------------------
// ---- sorting ---------------------------------------------------------------------------------
/// One way to sort a list. `key` is Plex's sort key; `desc` is the direction it starts in.
struct SortOption {
let title: String
let key: String
var desc = false
var up = "Ascending"
var down = "Descending"
/// Plex reverses a compound key by its first field.
func param(_ reversed: Bool) -> String {
guard desc != reversed else { return key }
var fields = key.split(separator: ",").map(String.init)
fields[0] += ":desc"
return fields.joined(separator: ",")
}
}
private let AZ = (up: "A to Z", down: "Z to A")
private let NEW = (up: "Oldest First", down: "Newest First")
private let MOST = (up: "Fewest First", down: "Most First")
let ARTIST_SORTS = [
SortOption(title: "Name", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
]
let ALBUM_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Artist", key: "artist.titleSort,year", up: AZ.up, down: AZ.down),
SortOption(title: "Year", key: "year", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Release Date", key: "originallyAvailableAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
]
let SONG_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Artist", key: "artist.titleSort,album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
SortOption(title: "Album", key: "album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
]
let PLAYLIST_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Updated", key: "updatedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
]
/// A screen's sort choice, kept across launches under `sort.<name>`.
struct SortState: DynamicProperty {
let options: [SortOption]
@AppStorage private var index: Int
@AppStorage private var reversed: Bool
init(_ name: String, _ options: [SortOption]) {
self.options = options
_index = AppStorage(wrappedValue: 0, "sort.\(name)")
_reversed = AppStorage(wrappedValue: false, "sort.\(name).rev")
}
var current: Int { options.indices.contains(index) ? index : 0 }
var option: SortOption { options[current] }
var param: String { option.param(reversed) }
/// The default order (first option, its own direction), where a letter index makes sense.
var isDefault: Bool { current == 0 && !reversed }
/// Picking the current field again flips it, like Plexamp.
func pick(_ i: Int) {
if i == current { reversed.toggle() } else { index = i; reversed = false }
}
func setDescending(_ d: Bool) { reversed = d != option.desc }
var descending: Bool { option.desc != reversed }
}
/// Toolbar menu: the sort fields, then the direction for the chosen one.
struct SortMenu: View {
let sort: SortState
var body: some View {
Menu {
Section("Sort By") {
ForEach(sort.options.indices, id: \.self) { i in
Button { sort.pick(i) } label: {
if i == sort.current {
Label(sort.options[i].title, systemImage: sort.descending ? "chevron.down" : "chevron.up")
} else {
Text(sort.options[i].title)
}
}
}
}
Section {
Button { sort.setDescending(false) } label: {
if sort.descending { Text(sort.option.up) } else { Label(sort.option.up, systemImage: "checkmark") }
}
Button { sort.setDescending(true) } label: {
if sort.descending { Label(sort.option.down, systemImage: "checkmark") } else { Text(sort.option.down) }
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
}
}
// ---- artists ---------------------------------------------------------------------------------
struct ArtistsScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
private let sort = SortState("artists", ARTIST_SORTS)
@State private var artists: LoadState<[Artist]> = .loading
@State private var reload = 0
@@ -135,14 +257,15 @@ struct ArtistsScreen: View {
}
.background(p.bg)
.overlay(alignment: .trailing) {
if let list = artists.value, list.count > 30 {
if sort.isDefault, let list = artists.value, list.count > 30 {
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
}
}
}
.navigationTitle("Artists")
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() }
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
}
}
}
@@ -195,11 +318,9 @@ private struct SectionIndex: View {
// ---- albums ---------------------------------------------------------------------------------
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
struct AlbumsScreen: View {
@Environment(Ui.self) private var ui
@State private var sort = 0
private let sort = SortState("albums", ALBUM_SORTS)
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
@@ -210,16 +331,10 @@ struct AlbumsScreen: View {
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sort") {
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
})
}
}
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
}
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) }
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
}
}
}
@@ -233,6 +348,9 @@ struct SongsScreen: View {
@State private var total = -1
@State private var loading = false
@State private var error: String?
/// Bumped when the sort changes, so a page still loading for the old order is dropped.
@State private var gen = 0
private let sort = SortState("songs", SONG_SORTS)
private var g: AppGraph { .shared }
/// Shuffles the whole library, not just the pages loaded so far.
@@ -264,17 +382,25 @@ struct SongsScreen: View {
if loading { LoadingRow() }
if let error { ErrorRow(message: error) { loadMore() } }
}
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
.task { if tracks.isEmpty { loadMore() } }
.onChange(of: sort.param) {
gen += 1; tracks = []; total = -1; error = nil; loading = false
loadMore()
}
}
private func loadMore() {
if loading || (total >= 0 && tracks.count >= total) { return }
loading = true
let mine = gen, order = sort.param
Task {
do {
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200)
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200, sort: order)
guard mine == gen else { return }
tracks += page; total = n; error = nil
} catch {
guard mine == gen else { return }
self.error = error.localizedDescription
}
loading = false
+4 -2
View File
@@ -6,6 +6,7 @@ struct PlaylistsScreen: View {
@Environment(Ui.self) private var ui
@State private var lists: LoadState<[Playlist]> = .loading
@State private var reload = 0
private let sort = SortState("playlists", PLAYLIST_SORTS)
private var g: AppGraph { .shared }
var body: some View {
@@ -21,12 +22,13 @@ struct PlaylistsScreen: View {
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
ToolbarItem(placement: .topBarTrailing) {
Button { newPlaylist() } label: { Image(systemName: "plus") }
}
}
.task(id: "\(g.settings.musicSection)|\(reload)") {
await LoadState.load($lists) { try await g.plex.playlists() }
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($lists) { try await g.plex.playlists(sort: sort.param) }
}
}