Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -6,26 +6,34 @@ struct LibraryHome: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var played: [Album] = []
|
||||
@State private var added: [Track] = []
|
||||
@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 ready = s.libraryReady
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if !played.isEmpty {
|
||||
SectionTitle(title: "Recent Plays")
|
||||
RecentShelf(albums: played)
|
||||
}
|
||||
if !added.isEmpty {
|
||||
SectionTitle(title: "Recently Added")
|
||||
ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(added, start: i) }
|
||||
.listRow(inset: MARGIN + 60, line: i < added.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
SectionTitle(title: "On This Day")
|
||||
@@ -52,10 +60,48 @@ struct LibraryHome: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
SectionTitle(title: "Stations")
|
||||
let stations: [(String, String, () async throws -> [Track])] = [
|
||||
("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.randomTracks(200) }),
|
||||
("square.stack", "Random Album Radio", {
|
||||
var out: [Track] = []
|
||||
for a in try await g.plex.randomAlbums(10) { out += try await g.plex.albumTracks(a.ratingKey) }
|
||||
return out
|
||||
}),
|
||||
]
|
||||
ForEach(stations, id: \.1) { st in
|
||||
LibraryRow(icon: st.0, title: st.1, last: st.1 == stations.last?.1) { play(st.2) }
|
||||
}
|
||||
}
|
||||
// Every tab except Settings (and this one).
|
||||
SectionTitle(title: "Browse")
|
||||
let rows: [(String, String, Route)] = [
|
||||
("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists),
|
||||
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs), ("guitars.fill", "Genres", .Genres),
|
||||
("arrow.down.circle", "Downloads", .Downloads), ("antenna.radiowaves.left.and.right", "Web Radio", .WebRadio),
|
||||
]
|
||||
ForEach(rows, id: \.1) { r in
|
||||
LibraryRow(icon: r.0, title: r.1, last: r.1 == rows.last?.1) { ui.push(r.2) }
|
||||
}
|
||||
}
|
||||
.task(id: "\(s.musicSection)|\(reload)") {
|
||||
.task(id: "\(s.libraryKey)|\(reload)") {
|
||||
guard ready else { return }
|
||||
async let rp = try? g.plex.recentlyPlayed(count: 24)
|
||||
async let ra = try? g.plex.recentlyAddedTracks(count: 10)
|
||||
await LoadState.load($albums) { try await g.plex.albums() }
|
||||
played = Array((await rp ?? []).prefix(24))
|
||||
added = Array((await ra ?? []).prefix(10))
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a station: its songs play in the order given (albums stay whole).
|
||||
private func play(_ load: @escaping () async throws -> [Track]) {
|
||||
Task {
|
||||
do {
|
||||
let list = try await load()
|
||||
if list.isEmpty { ui.overlays.hud("Nothing to play", "music.note") } else { g.playback.playTracks(list) }
|
||||
} catch { ui.overlays.hud("Couldn't start station", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +133,35 @@ private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday
|
||||
return (onDay, week)
|
||||
}
|
||||
|
||||
/// Recent plays as a two-row sideways shelf of small tiles, like Plexamp's, in iOS dress.
|
||||
private struct RecentShelf: View {
|
||||
let albums: [Album]
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private let size: CGFloat = 112
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: Array(repeating: GridItem(.fixed(size + 40), spacing: 14), count: albums.count > 4 ? 2 : 1), spacing: 12) {
|
||||
ForEach(albums) { a in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: size, corner: 8, placeholder: "square.stack")
|
||||
Text(a.title).font(.footnote.weight(.medium)).foregroundStyle(p.label).lineLimit(1).padding(.top, 5)
|
||||
Text(a.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
.frame(width: size, alignment: .topLeading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.push(.AlbumPage(a)) }
|
||||
.actionsMenu(ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LibraryRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@@ -236,6 +311,7 @@ struct SortMenu: View {
|
||||
} }
|
||||
Section {
|
||||
Toggle("Stems Only", systemImage: "waveform", isOn: Bindable(AppGraph.shared.settings).stemsOnly)
|
||||
Toggle("No Stems", systemImage: "waveform.slash", isOn: Bindable(AppGraph.shared.settings).noStems)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
@@ -248,13 +324,15 @@ struct SortMenu: View {
|
||||
@MainActor
|
||||
enum StemsOnly {
|
||||
private static var g: AppGraph { .shared }
|
||||
static var on: Bool { g.settings.stemsOnly && g.stems.index.size > 0 }
|
||||
static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { g.stems.find($0) != nil } : list }
|
||||
static var on: Bool { (g.settings.stemsOnly || g.settings.noStems) && g.stems.index.size > 0 }
|
||||
/// With No Stems the filter keeps what Stems Only would drop.
|
||||
private static var want: Bool { !g.settings.noStems }
|
||||
static func tracks(_ list: [Track]) -> [Track] { on ? list.filter { (g.stems.find($0) != nil) == want } : list }
|
||||
static func albums(_ list: [Album]) -> [Album] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
on && g.coverage.ready ? list.filter { ((g.coverage.albums[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list
|
||||
}
|
||||
static func artists(_ list: [Artist]) -> [Artist] {
|
||||
on && g.coverage.ready ? list.filter { (g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0 } : list
|
||||
on && g.coverage.ready ? list.filter { ((g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +371,7 @@ struct ArtistsScreen: View {
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -352,17 +430,24 @@ struct AlbumsScreen: View {
|
||||
private let sort = SortState("albums", ALBUM_SORTS)
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
/// Pinch out for fewer, bigger albums; pinch in for more.
|
||||
@AppStorage("albumColumns") private var columns = 2
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Albums") {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
AlbumGrid(albums: StemsOnly.albums(list), menu: { ui.menu.album($0) }, columns: columns, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(MagnifyGesture().onEnded { v in
|
||||
let n = v.magnification < 0.85 ? min(columns + 1, 5) : v.magnification > 1.15 ? max(columns - 1, 2) : columns
|
||||
if n != columns { withAnimation(.spring(duration: 0.35)) { columns = n } }
|
||||
})
|
||||
.sensoryFeedback(.selection, trigger: columns)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.token)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(sort.token)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
@@ -415,7 +500,7 @@ struct SongsScreen: View {
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.musicSection)") {
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") {
|
||||
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
|
||||
loadMore()
|
||||
}
|
||||
@@ -464,7 +549,7 @@ struct AlbumScreen: View {
|
||||
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 }
|
||||
let dl = g.downloads.group(.album, a.ratingKey)
|
||||
Page(title: a.title, large: false) {
|
||||
VStack(spacing: 0) {
|
||||
Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack")
|
||||
@@ -503,12 +588,13 @@ struct AlbumScreen: View {
|
||||
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) }
|
||||
if let dl {
|
||||
ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(dl.id) }
|
||||
} else {
|
||||
g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
g.downloads.download(DownloadGroup(kind: .album, key: a.ratingKey, title: a.title, subtitle: a.artist, thumb: a.thumb), list)
|
||||
ui.overlays.hud("Downloading", "arrow.down.circle")
|
||||
}
|
||||
} label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") }
|
||||
} label: { Image(systemName: dl != nil ? "checkmark.circle" : "arrow.down.circle") }
|
||||
MoreMenu(items: ui.menu.album(a))
|
||||
}
|
||||
}
|
||||
@@ -551,137 +637,128 @@ struct ArtistScreen: View {
|
||||
|
||||
// ---- downloads ------------------------------------------------------------------------------
|
||||
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"]
|
||||
private let DOWNLOAD_SORTS = ["Download Order", "Name"]
|
||||
|
||||
/// Downloaded songs gathered back into their albums.
|
||||
private struct DownloadedAlbum {
|
||||
let album: Album
|
||||
/// A downloaded playlist, album or artist with its songs' download state.
|
||||
private struct DownloadedGroup {
|
||||
let group: DownloadGroup
|
||||
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
|
||||
/// In the group's own order (playlist order, album track order).
|
||||
var tracks: [Track] { records.filter(\.done).map(\.track) }
|
||||
/// Shown through [AlbumGrid], so the key carries the group id.
|
||||
var tile: Album { Album(ratingKey: group.id, title: group.title, artist: group.subtitle, thumb: group.thumb) }
|
||||
}
|
||||
|
||||
/// Downloads the way Plexamp does them: whole playlists, albums and artists, kept in step with the server.
|
||||
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 d = g.downloads
|
||||
let records = d.records
|
||||
let sort = min(g.settings.downloadSort, DOWNLOAD_SORTS.count - 1)
|
||||
let all = d.groups.values.map { gr in DownloadedGroup(group: gr, records: gr.trackKeys.compactMap { records[$0] }) }
|
||||
.sorted { sort == 1 ? $0.group.title.lowercased() < $1.group.title.lowercased() : $0.group.added > $1.group.added }
|
||||
let byId = Dictionary(all.map { ($0.group.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
let loose = d.looseRecords.map(\.track)
|
||||
let allDone = records.values.filter(\.done).map(\.track)
|
||||
let pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9)
|
||||
Page(title: "Downloads") {
|
||||
if albums.isEmpty {
|
||||
if all.isEmpty && loose.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.")
|
||||
message: "Hold any playlist, album or artist 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())")
|
||||
Text("\(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))
|
||||
})
|
||||
ForEach([("Playlists", [DownloadGroup.Kind.playlist, .stemPlaylist]), ("Albums", [.album]), ("Artists", [.artist])], id: \.0) { title, kinds in
|
||||
let list = all.filter { kinds.contains($0.group.kind) }
|
||||
if !list.isEmpty {
|
||||
SectionTitle(title: title)
|
||||
grid(list, byId)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !loose.isEmpty {
|
||||
SectionTitle(title: "Other Songs")
|
||||
ForEach(Array(loose.enumerated()), id: \.element.ratingKey) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(loose, start: i) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if !albums.isEmpty {
|
||||
if !all.isEmpty || !loose.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))
|
||||
MoreMenu(items: moreItems(all, 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 }) }
|
||||
.task { await g.downloads.refresh() }
|
||||
}
|
||||
|
||||
private func grid(_ list: [DownloadedGroup], _ byId: [String: DownloadedGroup]) -> some View {
|
||||
AlbumGrid(albums: list.map(\.tile), subtitle: { a in
|
||||
guard let d = byId[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)" }
|
||||
return a.artist == "Artist" || a.artist.hasSuffix("Playlist") ? "\(a.artist) · \(songs(d.records.count))" : a.artist
|
||||
}, menu: { a in
|
||||
guard let d = byId[a.ratingKey] else { return [] }
|
||||
var items = [
|
||||
SheetAction("Play", icon: "play.fill") { g.playback.playTracks(d.tracks) },
|
||||
SheetAction("Shuffle", icon: "shuffle") { g.playback.playTracks(d.tracks, shuffle: true) },
|
||||
]
|
||||
if d.failed > 0 { items.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
items.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove Download?", "“\(d.group.title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(d.group.id) }
|
||||
})
|
||||
return items
|
||||
}, open: { a in
|
||||
guard let gr = byId[a.ratingKey]?.group else { return }
|
||||
switch gr.kind {
|
||||
case .album: ui.push(.AlbumPage(Album(ratingKey: gr.key, title: gr.title, artist: gr.subtitle, thumb: gr.thumb)))
|
||||
case .artist: ui.push(.ArtistPage(Artist(ratingKey: gr.key, title: gr.title, sortTitle: gr.title, thumb: gr.thumb)))
|
||||
case .playlist: ui.push(.PlaylistPage(Playlist(ratingKey: gr.key, title: gr.title, thumb: gr.thumb, trackCount: gr.trackKeys.count)))
|
||||
case .stemPlaylist: ui.push(.StemPlaylistPage(gr.key))
|
||||
}
|
||||
}) { a in
|
||||
if let d = byId[a.ratingKey], !d.complete, !d.records.isEmpty {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] {
|
||||
private func moreItems(_ all: [DownloadedGroup], _ pinned: String) -> [SheetAction] {
|
||||
var a: [SheetAction] = []
|
||||
if albums.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
|
||||
if all.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") {
|
||||
for id in g.downloads.groups.keys { g.downloads.removeGroup(id) }
|
||||
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 -----------------------------------------------------------------------------------
|
||||
@@ -713,7 +790,7 @@ struct GenresScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
@@ -731,7 +808,7 @@ struct GenreScreen: View {
|
||||
AlbumGrid(albums: StemsOnly.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)") {
|
||||
.task(id: "\(AppGraph.shared.settings.libraryKey)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user