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:
1 parent
d149af336a
commit
65bc69a7fe
4 files changed
+155
-27
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user