diff --git a/ios/Stemify/Plex/PlexClient.swift b/ios/Stemify/Plex/PlexClient.swift index 9675541..d002152 100644 --- a/ios/Stemify/Plex/PlexClient.swift +++ b/ios/Stemify/Plex/PlexClient.swift @@ -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] { diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index 9a991e6..d0a86df 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -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.`. +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 diff --git a/ios/Stemify/UI/Screens/PlaylistScreens.swift b/ios/Stemify/UI/Screens/PlaylistScreens.swift index 86a2c9b..a1229d7 100644 --- a/ios/Stemify/UI/Screens/PlaylistScreens.swift +++ b/ios/Stemify/UI/Screens/PlaylistScreens.swift @@ -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) } } } diff --git a/ios/project.yml b/ios/project.yml index fc052ac..cc0b25b 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0.4" - CURRENT_PROJECT_VERSION: "5" + MARKETING_VERSION: "1.0.5" + CURRENT_PROJECT_VERSION: "6" targets: Stemify: type: application