import SwiftUI // ---- library home --------------------------------------------------------------------------- struct LibraryHome: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p @State private var albums: LoadState<[Album]> = .loading @State private var reload = 0 private var g: AppGraph { .shared } var body: some View { let s = g.settings let ready = !s.plexToken.isEmpty && !s.musicSection.isEmpty let today = Date() Page(title: "Library") { if !ready { EmptyState(icon: "server.rack", title: "Connect to Plex", message: "Sign in to your Plex server to browse your music. Stems come from stemserve on the same box.", action: "Connect") { ui.push(.PlexAccount) } } let rows: [(String, String, Route)] = [ ("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists), ("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ] ForEach(Array(rows.enumerated()), id: \.offset) { i, r in LibraryRow(icon: r.0, title: r.1, last: i == rows.count - 1) { ui.push(r.2) } } if ready { SectionTitle(title: "On This Day") Text(today.formatted(.dateTime.month(.wide).day())).font(.subheadline).foregroundStyle(p.label2) .padding(.leading, MARGIN).padding(.bottom, 10) Loaded(state: albums, retry: { reload += 1 }) { list in let (onDay, week) = birthdays(list, today) if !onDay.isEmpty { AlbumGrid(albums: onDay.map(\.album), subtitle: { a in onDay.first { $0.album.ratingKey == a.ratingKey }.map { "\(a.artist) · \(yearsAgo($0.years))" } }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }) } else { Text(week.isEmpty ? "No albums in your library came out this week in any year." : "No album birthdays today. Coming up and just past:") .font(.subheadline).foregroundStyle(p.label2).lineLimit(3) .padding(.horizontal, MARGIN).padding(.bottom, 12) if !week.isEmpty { AlbumGrid(albums: week.map(\.album), subtitle: { a in week.first { $0.album.ratingKey == a.ratingKey }.map { b in let verb = b.date < Calendar.current.startOfDay(for: today) ? "Turned" : "Turns" return "\(verb) \(b.years) · \(b.date.formatted(.dateTime.month(.abbreviated).day()))" } }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }) } } } } } .task(id: "\(s.musicSection)|\(reload)") { guard ready else { return } await LoadState.load($albums) { try await g.plex.albums() } } } } /// An album's anniversary: its release day this year, and how old it is then. private struct Birthday { let album: Album; let date: Date; let years: Int } private func yearsAgo(_ n: Int) -> String { n == 1 ? "1 year ago" : "\(n) years ago" } /// Albums released on today's date in earlier years, and, for slow days, the three days either side. private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday]) { let cal = Calendar(identifier: .gregorian) let today = cal.startOfDay(for: now) let year = cal.component(.year, from: today) let all: [Birthday] = albums.compactMap { a in guard let r = a.released, r.count >= 10 else { return nil } let parts = r.prefix(10).split(separator: "-").compactMap { Int($0) } guard parts.count == 3, parts[0] < year else { return nil } var day = parts[2] // Feb 29 falls back to Feb 28 in other years. if parts[1] == 2, day == 29, cal.range(of: .day, in: .month, for: cal.date(from: DateComponents(year: year, month: 2))!)!.count < 29 { day = 28 } guard let d = cal.date(from: DateComponents(year: year, month: parts[1], day: day)) else { return nil } return Birthday(album: a, date: d, years: year - parts[0]) } let onDay = all.filter { $0.date == today }.sorted { $0.years > $1.years } let week = all.filter { b in b.date != today && abs(cal.dateComponents([.day], from: today, to: b.date).day ?? 99) <= 3 }.sorted { ($0.date, -$0.years) < ($1.date, -$1.years) } return (onDay, week) } private struct LibraryRow: View { let icon: String let title: String let last: Bool let action: () -> Void @Environment(\.palette) private var p var body: some View { Button(action: action) { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26) Text(title).font(.title3).foregroundStyle(p.label) Spacer() Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3) } .frame(height: 52) .contentShape(Rectangle()) } .buttonStyle(.plain) .listRow(inset: 60, trailing: 16, line: !last) } } // ---- 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 var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { Loaded(state: artists, retry: { reload += 1 }) { list in ForEach(list) { a in Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) } .buttonStyle(.plain) .actionsMenu(ui.menu.artist(a)) .listRow(inset: MARGIN + 58, trailing: 28) .id(a.ratingKey) } } } .padding(.bottom, 24) } .background(p.bg) .overlay(alignment: .trailing) { 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") .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) } } } } private func letterOf(_ s: String) -> Character { var t = s.drop { $0.isWhitespace } if t.hasPrefix("The ") { t = t.dropFirst(4) } guard let ch = t.first?.uppercased().first, ("A"..."Z").contains(ch) else { return "#" } return ch } /// UITableView's section index: drag down the letters to jump. private struct SectionIndex: View { let titles: [String] let jump: (Int) -> Void @Environment(\.palette) private var p @State private var last: Character? private static let letters: [Character] = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ#") var body: some View { let first: [Character: Int] = titles.enumerated().reduce(into: [:]) { m, e in let l = letterOf(e.element) if m[l] == nil { m[l] = e.offset } } GeometryReader { outer in VStack(spacing: 0) { ForEach(Self.letters, id: \.self) { l in Text(String(l)).font(.caption2.weight(.semibold)).foregroundStyle(p.accent).frame(maxHeight: .infinity) } } .frame(width: 22, height: outer.size.height * 0.62) .contentShape(Rectangle()) .gesture(DragGesture(minimumDistance: 0) .onChanged { v in let h = outer.size.height * 0.62 let i = min(max(Int(v.location.y / h * CGFloat(Self.letters.count)), 0), Self.letters.count - 1) let l = Self.letters[i] if l == last { return } last = l if let target = Self.letters[i...].lazy.compactMap({ first[$0] }).first { jump(target) } } .onEnded { _ in last = nil }) .frame(maxHeight: .infinity) .padding(.trailing, 2) } .frame(width: 24) .sensoryFeedback(.selection, trigger: last) { _, new in new != nil } } } // ---- albums --------------------------------------------------------------------------------- struct AlbumsScreen: View { @Environment(Ui.self) private var ui private let sort = SortState("albums", ALBUM_SORTS) @State private var albums: LoadState<[Album]> = .loading @State private var reload = 0 var body: some View { Page(title: "Albums") { Loaded(state: albums, retry: { reload += 1 }) { list in AlbumGrid(albums: list, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) } } .toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } .task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") { await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) } } } } // ---- songs (paged) -------------------------------------------------------------------------- struct SongsScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p @State private var tracks: [Track] = [] @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. private func shuffleAll() { Task { do { g.playback.playTracks(try await g.plex.randomTracks(1000), shuffle: true) } catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") } } } var body: some View { Page(title: "Songs") { if !tracks.isEmpty { if total > 0 { Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12) } PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) }, shuffle: shuffleAll) .padding(.bottom, 12) } ForEach(Array(tracks.enumerated()), id: \.element.id) { i, t in TrackRow(track: t, menu: ui.menu.track(t)) { let from = max(i - 200, 0) g.playback.playTracks(Array(tracks[from.. tracks.count - 40 { loadMore() } } } 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, 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 } } } // ---- album & artist pages ------------------------------------------------------------------- struct AlbumScreen: View { let album: Album @Environment(Ui.self) private var ui @Environment(\.palette) private var p @Environment(\.displayScale) private var scale @State private var tracks: LoadState<[Track]> = .loading @State private var detail: Album? @State private var reload = 0 private var g: AppGraph { .shared } var body: some View { let a = detail ?? album let list = tracks.value ?? [] let allDownloaded = !list.isEmpty && list.allSatisfy { g.downloads.records[$0.ratingKey]?.done == true } Page(title: a.title, large: false) { VStack(spacing: 0) { Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack") Text(a.title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2) .padding(.horizontal, 32).padding(.top, 16) Button { if let k = a.artistKey { ui.push(.ArtistPage(Artist(ratingKey: k, title: a.artist, sortTitle: a.artist))) } } label: { Text(a.artist).font(.title3).foregroundStyle(p.accent).multilineTextAlignment(.center) } .buttonStyle(.plain) .padding(.horizontal, 32) let meta = [a.genre?.uppercased(), a.year.map(String.init)].compactMap { $0 }.joined(separator: " · ") if !meta.isEmpty { Text(meta).font(.caption.weight(.semibold)).foregroundStyle(p.label2).padding(.top, 4) } PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) }) .padding(.vertical, 16) p.separator.frame(height: 1 / scale).padding(.leading, MARGIN) } .frame(maxWidth: .infinity) .padding(.top, 8) Loaded(state: tracks, retry: { reload += 1 }) { list in let discs = Set(list.compactMap(\.disc)).count > 1 ForEach(Array(list.enumerated()), id: \.element.id) { i, t in if discs, let d = t.disc, i == 0 || list[i - 1].disc != d { Text("Disc \(d)").font(.headline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 18).padding(.bottom, 6) } TrackRow(track: t, number: t.index ?? i + 1, showArt: false, subtitle: t.artist != a.artist ? t.artist : nil, menu: ui.menu.track(t, allowGoTo: false)) { g.playback.playTracks(list, start: i) } .listRow(inset: MARGIN + 32) } Text("\(songs(list.count)), \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))") .font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 14) } } .toolbar { if !list.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) { Button { if allDownloaded { ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.remove(list) } } else { g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle") } } label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") } MoreMenu(items: ui.menu.album(a)) } } } .task(id: reload) { async let d = try? g.plex.album(album.ratingKey) await LoadState.load($tracks) { try await g.plex.albumTracks(album.ratingKey) } if let d = await d ?? nil { detail = d } } } } struct ArtistScreen: View { let artist: Artist @Environment(Ui.self) private var ui @State private var albums: LoadState<[Album]> = .loading @State private var reload = 0 private var g: AppGraph { .shared } var body: some View { Page(title: artist.title) { PlayShuffle(play: { playAll(false) }, shuffle: { playAll(true) }).padding(.top, 6).padding(.bottom, 18) Loaded(state: albums, retry: { reload += 1 }) { list in AlbumGrid(albums: list, showArtist: false, menu: { ui.menu.album($0, allowGoTo: false) }, open: { ui.push(.AlbumPage($0)) }) } } .toolbar { ToolbarItem(placement: .topBarTrailing) { MoreMenu(items: ui.menu.artist(artist)) } } .task(id: reload) { await LoadState.load($albums) { try await g.plex.artistAlbums(artist.ratingKey).sorted { ($0.year ?? 0) > ($1.year ?? 0) } } } } private func playAll(_ shuffle: Bool) { Task { do { g.playback.playTracks(try await g.plex.artistTracks(artist.ratingKey), shuffle: shuffle) } catch { ui.overlays.hud("Couldn't load songs", "exclamationmark.triangle") } } } } // ---- downloads ------------------------------------------------------------------------------ private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"] /// Downloaded songs gathered back into their albums. private struct DownloadedAlbum { let album: Album let records: [DownloadRecord] let released: String? var added: Int64 { records.map(\.added).max() ?? 0 } var done: Int { records.filter(\.done).count } var failed: Int { records.filter { $0.error != nil }.count } var complete: Bool { done == records.count } var tracks: [Track] { records.map(\.track).sorted { ($0.disc ?? 0, $0.index ?? 0, $0.title.lowercased()) < ($1.disc ?? 0, $1.index ?? 0, $1.title.lowercased()) } } } private func artistSortKey(_ s: String) -> String { let t = s.trimmingCharacters(in: .whitespaces).lowercased() return t.hasPrefix("the ") ? String(t.dropFirst(4)) : t } struct DownloadsScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p // Release days come from the album list (served from the HTTP cache when the server's away). @State private var info: [String: Album] = [:] private var g: AppGraph { .shared } var body: some View { let records = g.downloads.records let sort = g.settings.downloadSort let albums = group(records, sort) let byKey = Dictionary(albums.map { ($0.album.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) let allDone = albums.flatMap { $0.tracks.filter { records[$0.ratingKey]?.done == true } } let pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9) Page(title: "Downloads") { if albums.isEmpty { EmptyState(icon: "arrow.down.circle", title: "Nothing Downloaded", message: "Hold any album, artist, playlist or song and choose Download to play it, stems and all, without your server.") } else { Text("\(albums.count) album\(albums.count == 1 ? "" : "s") · \(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())") .font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12) PlayShuffle(play: { g.playback.playTracks(allDone) }, shuffle: { g.playback.playTracks(allDone, shuffle: true) }) .padding(.bottom, 14) AlbumGrid(albums: albums.map(\.album), subtitle: { a in guard let d = byKey[a.ratingKey] else { return a.artist } if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" } if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" } if sort == 2, let r = d.released { return "\(a.artist) · \(r.prefix(4))" } return a.artist }, menu: { a in guard let d = byKey[a.ratingKey] else { return [] } var extra: [SheetAction] = [] if d.failed > 0 { extra.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) } if !d.complete { extra.append(SheetAction("Cancel Download", icon: "xmark.circle", destructive: true) { g.downloads.remove(d.records.filter { !$0.done }.map(\.track)) }) } return a.ratingKey.hasPrefix("name:") ? ui.menu.tracks("\(a.title) — \(a.artist)", d.tracks, extra: extra) : ui.menu.album(a, extra: extra) }, open: { a in guard let d = byKey[a.ratingKey] else { return } if a.ratingKey.hasPrefix("name:") { g.playback.playTracks(d.tracks) } else { ui.push(.AlbumPage(a)) } }) { a in if let d = byKey[a.ratingKey], !d.complete { let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) } ZStack { Circle().fill(Color.black.opacity(0.55)) if d.failed > 0 { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white) } else { ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white) } } .frame(width: 30, height: 30) } } } } .toolbar { if !albums.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) { Button("Sort") { ui.overlays.actions("Sort By", DOWNLOAD_SORTS.enumerated().map { i, label in SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i } }) } MoreMenu(items: moreItems(albums, pinned)) } } } .task(id: g.settings.musicSection) { guard g.plex.signedIn else { return } if let list = try? await g.plex.albums() { info = Dictionary(list.map { ($0.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) } } } private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] { var a: [SheetAction] = [] if albums.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) } a.append(SheetAction("Remove All Downloads", icon: "trash", destructive: true) { ui.overlays.confirm("Remove All Downloads?", "Frees \(pinned) GB. Songs stay in your library.", "Remove All") { g.downloads.remove(g.downloads.records.values.map(\.track)) } }) return a } private func group(_ records: [String: DownloadRecord], _ sort: Int) -> [DownloadedAlbum] { let grouped = Dictionary(grouping: records.values) { r in r.track.albumKey ?? "name:\(r.track.artist)|\(r.track.album)" }.map { key, rs in let t = rs[0].track let known = info[key] let artists = Set(rs.map(\.track.artist)) let album = known ?? Album(ratingKey: key, title: t.album.isEmpty ? "Unknown Album" : t.album, artist: artists.count > 1 ? "Various Artists" : t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb) return DownloadedAlbum(album: album, records: rs, released: known?.released ?? (known?.year ?? t.year).map(String.init)) } switch sort { case 1: return grouped.sorted { (artistSortKey($0.album.artist), $0.released ?? "", $0.album.title.lowercased()) < (artistSortKey($1.album.artist), $1.released ?? "", $1.album.title.lowercased()) } case 2: return grouped.sorted { a, b in (a.released ?? "") != (b.released ?? "") ? (a.released ?? "") > (b.released ?? "") : a.album.title.lowercased() < b.album.title.lowercased() } default: return grouped.sorted { $0.added > $1.added } } } } // ---- genres ----------------------------------------------------------------------------------- struct GenresScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p @State private var albums: LoadState<[Album]> = .loading @State private var reload = 0 var body: some View { Page(title: "Genres") { Loaded(state: albums, retry: { reload += 1 }) { list in let counts = Dictionary(grouping: list.compactMap(\.genre).filter { !$0.isEmpty }, by: { $0 }).mapValues(\.count) LazyVStack(spacing: 0) { ForEach(counts.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { g in Button { ui.push(.GenrePage(g)) } label: { HStack { Text(g).foregroundStyle(p.label) Spacer() Text("\(counts[g] ?? 0)").foregroundStyle(p.label2) Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label2) } .padding(.horizontal, 16).padding(.vertical, 12) } .buttonStyle(.plain) Divider().padding(.leading, 16) } } } } .task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") } } } } struct GenreScreen: View { @Environment(Ui.self) private var ui let genre: String @State private var albums: LoadState<[Album]> = .loading @State private var reload = 0 var body: some View { Page(title: genre) { Loaded(state: albums, retry: { reload += 1 }) { list in AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8) } } .task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") { await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") } } } }