Files
2026-11-06 08:55:26 -08:00

981 lines
47 KiB
Swift

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 played: [Album] = []
@State private var added: [Track] = []
@State private var reload = 0
@Environment(\.scenePhase) private var scenePhase
@State private var editing = false
private var g: AppGraph { .shared }
/// Rows in the saved order. Titles not saved yet keep their default order after the saved ones.
private func ordered<T>(_ items: [T], _ title: (T) -> String) -> [T] {
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
func rank(_ t: String) -> Int { saved.firstIndex(of: t) ?? saved.count }
return items.enumerated().sorted { (rank(title($0.element)), $0.offset) < (rank(title($1.element)), $1.offset) }.map(\.element)
}
/// Moves a row one place within its section and saves the whole order.
private func move(_ title: String, _ by: Int, in titles: [String]) {
guard let i = titles.firstIndex(of: title), titles.indices.contains(i + by) else { return }
var t = titles
t.swapAt(i, i + by)
let saved = g.settings.libraryOrder.split(separator: "|").map(String.init)
g.settings.libraryOrder = (t + saved.filter { !t.contains($0) }).joined(separator: "|")
}
private static let sectionNames = ["Recent Plays", "Recently Added", "On This Day", "Stations", "Browse"]
private var shownSections: [String] { ordered(Self.sectionNames) { $0 } }
private var hiddenSections: Set<String> { Set(g.settings.libraryHidden.split(separator: "|").map(String.init)) }
private func toggle(_ name: String) {
var h = hiddenSections
if h.contains(name) { h.remove(name) } else { h.insert(name) }
g.settings.libraryHidden = Self.sectionNames.filter(h.contains).joined(separator: "|")
}
@ViewBuilder private func section(_ name: String) -> some View {
let today = Date()
switch name {
case "Recent Plays":
if !played.isEmpty {
SectionTitle(title: "Recent Plays")
RecentShelf(albums: played)
}
case "Recently Added":
if !added.isEmpty {
SectionTitle(title: "Recently Added", action: "See All") { ui.push(.RecentlyAdded) }
ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in
TrackRow(track: t, menu: ui.menu.track(t)) { playRecent(from: t, at: i) }
.listRow(inset: MARGIN + 60, line: i < added.count - 1)
}
}
case "On This Day":
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)) })
}
}
}
case "Stations":
SectionTitle(title: "Stations")
let stations: [(String, String, () async throws -> Void)] = [
("dot.radiowaves.left.and.right", "Library Radio", {
try await g.playback.startSongRadio()
}),
("square.stack", "Random Album Radio", { try await g.playback.startAlbumRadio() }),
]
let shown = ordered(stations) { $0.1 }
ForEach(shown, id: \.1) { st in
LibraryRow(icon: st.0, title: st.1, last: st.1 == shown.last?.1, editing: editing,
move: { move(st.1, $0, in: shown.map(\.1)) }) { play(st.2) }
}
default:
// 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),
]
let shownRows = ordered(rows) { $0.1 }
ForEach(shownRows, id: \.1) { r in
LibraryRow(icon: r.0, title: r.1, last: r.1 == shownRows.last?.1, editing: editing,
move: { move(r.1, $0, in: shownRows.map(\.1)) }) { ui.push(r.2) }
}
}
}
var body: some View {
let s = g.settings
let ready = s.libraryReady
Page(title: "Library") {
if !ready {
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)
}
}
if ready {
ForEach(shownSections, id: \.self) { name in
let off = hiddenSections.contains(name)
if editing {
SectionEditBar(title: name, off: off,
move: { move(name, $0, in: shownSections) },
toggle: { toggle(name) })
}
if !off || editing {
section(name).opacity(off ? 0.35 : 1).allowsHitTesting(!editing || !off)
}
}
}
}
.toolbar {
if ready {
ToolbarItem(placement: .topBarTrailing) {
Button(editing ? "Done" : "Reorder") { withAnimation { editing.toggle() } }
}
}
}
.onChange(of: scenePhase) { _, ph in if ph == .active { reload += 1 } }
.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))
if await g.stemPlaylists.refresh(albumTracks: { try await g.plex.albumTracks($0) }, search: { try await g.plex.searchTracks($0) }) { await g.downloads.refresh() }
}
}
/// Plays the whole Recently Added list (up to 500 songs) from [t], not just the 10 shown here.
private func playRecent(from t: Track, at i: Int) {
let shown = added
Task {
let all = (try? await g.plex.recentlyAddedTracks(count: 500)) ?? []
if let j = all.firstIndex(where: { $0.ratingKey == t.ratingKey }) { g.playback.playTracks(all, start: j) }
else { g.playback.playTracks(shown, start: i) }
}
}
/// Starts a station: its songs play in the order given (albums stay whole).
private func play(_ start: @escaping () async throws -> Void) {
Task {
do { try await start() }
catch { ui.overlays.hud("Couldn't start station", "exclamationmark.triangle") }
}
}
}
/// 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)
}
/// 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)
}
}
/// Edit-mode header for a whole section: move it or switch it on/off.
private struct SectionEditBar: View {
let title: String
let off: Bool
let move: (Int) -> Void
let toggle: () -> Void
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 18) {
Text(title).font(.headline).foregroundStyle(p.label)
Spacer()
Image(systemName: off ? "eye.slash" : "eye").onTapGesture { toggle() }
Image(systemName: "chevron.up").onTapGesture { move(-1) }
Image(systemName: "chevron.down").onTapGesture { move(1) }
}
.font(.system(size: 17, weight: .semibold)).foregroundStyle(p.accent)
.padding(.horizontal, MARGIN).padding(.vertical, 10)
.background(p.label3.opacity(0.12))
}
}
private struct LibraryRow: View {
let icon: String
let title: String
let last: Bool
var editing = false
var move: (Int) -> Void = { _ in }
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: { if !editing { 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()
if editing {
HStack(spacing: 18) {
Image(systemName: "chevron.up").onTapGesture { move(-1) }
Image(systemName: "chevron.down").onTapGesture { move(1) }
}
.font(.system(size: 17, weight: .semibold)).foregroundStyle(p.accent)
} else {
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"
/// Random has no direction; picking it again reshuffles.
var random: Bool { key == "random" }
/// Plex reverses a compound key by its first field.
func param(_ reversed: Bool) -> String {
guard !random, 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")
private let RANDOM = SortOption(title: "Random", key: "random", up: "Shuffled", down: "Shuffled")
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"),
RANDOM,
]
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"),
RANDOM,
]
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"),
RANDOM,
]
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"),
RANDOM,
]
/// 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) }
/// Changes whenever the list should reload, including a reshuffle of Random.
var token: String { "\(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)
}
}
}
}
if !sort.option.random { 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) }
}
} }
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")
}
}
}
/// The Stems Only filter. It only applies once there's a stems index (and, for albums and
/// artists, once the library has been matched against it), so nothing vanishes while loading.
@MainActor
enum StemsOnly {
private static var g: AppGraph { .shared }
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) == want } : list
}
static func artists(_ list: [Artist]) -> [Artist] {
on && g.coverage.ready ? list.filter { ((g.coverage.artists[$0.ratingKey]?.withStems ?? 0) > 0) == want } : list
}
}
// ---- 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 }) { all in
let list = StemsOnly.artists(all)
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.map({ StemsOnly.artists($0) }), 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.libraryKey)|\(sort.token)|\(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
/// 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) }, 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.libraryKey)|\(sort.token)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
}
}
}
// ---- songs (paged) --------------------------------------------------------------------------
struct SongsScreen: View {
/// Recently Added: every song, newest first, with no sort menu.
var recent = false
@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
/// How far into the unfiltered listing we've read; Stems Only drops rows as they arrive.
@State private var offset = 0
private let sort = SortState("songs", SONG_SORTS)
private var g: AppGraph { .shared }
/// Shuffles the whole library, not just the pages loaded so far: random songs from the server,
/// topped up as they play. Recently Added shuffles what it has loaded.
private func shuffleAll() {
if recent { g.playback.playTracks(tracks, shuffle: true); return }
Task {
do { try await g.playback.startSongRadio() }
catch { ui.overlays.hud("Couldn't shuffle songs", "exclamationmark.triangle") }
}
}
private var order: String { recent ? "addedAt:desc" : sort.param }
var body: some View {
Page(title: recent ? "Recently Added" : "Songs") {
if !tracks.isEmpty {
if total > 0, !StemsOnly.on, recent || !sort.option.random {
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..<min(i + 800, tracks.count)]), start: i - from)
}
.listRow(inset: MARGIN + 60)
.onAppear { if i > tracks.count - 40 { loadMore() } }
}
if loading { LoadingRow() }
if let error { ErrorRow(message: error) { loadMore() } }
}
.toolbar { if !recent { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } }
.task { if tracks.isEmpty { loadMore() } }
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") {
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
loadMore()
}
}
private func loadMore() {
if loading || (total >= 0 && offset >= total) { return }
loading = true
let mine = gen, order = self.order, random = !recent && sort.option.random
Task {
do {
// Random pages would repeat songs, so it's one shuffled batch.
let page: [Track], n: Int
if random {
page = try await g.plex.randomTracks(1000); n = page.count
} else {
let r = try await g.plex.tracksPage(start: offset, size: 200, sort: order)
page = r.0; n = r.1
}
guard mine == gen else { return }
let kept = StemsOnly.tracks(page)
tracks += kept; offset += page.count; total = n; error = nil
// A page that the filter mostly emptied leaves nothing on screen to trigger the next one.
if kept.count < 40, !page.isEmpty, offset < n { loading = false; loadMore(); return }
} 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 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")
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 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(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: dl != nil ? "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", "Name"]
/// A downloaded playlist, album or artist with its songs' download state.
private struct DownloadedGroup {
let group: DownloadGroup
let records: [DownloadRecord]
var done: Int { records.filter(\.done).count }
var failed: Int { records.filter { $0.error != nil }.count }
var complete: Bool { done == records.count }
/// 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
private var g: AppGraph { .shared }
var body: some View {
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 all.isEmpty && loose.isEmpty {
EmptyState(icon: "arrow.down.circle", title: "Nothing Downloaded",
message: "Hold any playlist, album or artist and choose Download to play it, stems and all, without your server.")
} else {
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)
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)
}
}
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 !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(all, pinned) + [SheetAction("View Queue", icon: "list.bullet") { ui.push(.DownloadQueue) }])
}
}
}
.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("Update Download", icon: "arrow.triangle.2.circlepath") {
Task {
let ok = await g.downloads.update(d.group.id)
ui.overlays.hud(ok ? "Download Updated" : "Couldn't reach the server", ok ? "arrow.triangle.2.circlepath" : "exclamationmark.triangle")
}
})
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(_ all: [DownloadedGroup], _ pinned: String) -> [SheetAction] {
var a: [SheetAction] = []
if all.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
if !g.downloads.groups.isEmpty {
a.append(SheetAction("Update All Downloads", icon: "arrow.triangle.2.circlepath") {
Task {
var failed = 0
for id in Array(g.downloads.groups.keys) { if await !g.downloads.update(id) { failed += 1 } }
ui.overlays.hud(failed == 0 ? "Downloads Updated" : "\(failed) couldn't be updated", failed == 0 ? "arrow.triangle.2.circlepath" : "exclamationmark.triangle")
}
})
}
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
}
}
// ---- 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.libraryKey)|\(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: 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.libraryKey)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
}
}
}
struct DownloadQueueScreen: View {
private var g: AppGraph { .shared }
var body: some View {
let recs = g.downloads.records.values
let active = recs.filter { !$0.done && $0.error == nil }.sorted { $0.added < $1.added }
let failed = recs.filter { $0.error != nil }.sorted { $0.added < $1.added }
let done = recs.filter(\.done).sorted { $0.added > $1.added }
List {
if active.isEmpty && failed.isEmpty && done.isEmpty {
Text("Nothing in the queue").foregroundStyle(.secondary)
}
if !active.isEmpty {
Section("In Progress · \(active.count)") {
ForEach(active, id: \.track.ratingKey) { r in
let p = g.downloads.progress[r.track.ratingKey] ?? 0
row(r, status: p > 0 ? "Downloading \(Int(p * 100))%" : "Waiting", progress: p > 0 ? p : nil)
}
}
}
if !failed.isEmpty {
Section("Failed · \(failed.count)") {
ForEach(failed, id: \.track.ratingKey) { r in row(r, status: r.error ?? "Failed", progress: nil) }
}
}
if !done.isEmpty {
Section("Completed · \(done.count)") {
ForEach(done, id: \.track.ratingKey) { r in row(r, status: r.stems ? "Done · stems" : "Done", progress: nil) }
}
}
}
.navigationTitle("Download Queue")
.toolbar {
if !failed.isEmpty {
Button("Retry Failed") { g.downloads.retryFailed() }
}
}
.task { await g.downloads.refresh() }
}
private func row(_ r: DownloadRecord, status: String, progress: Float?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(r.track.title).lineLimit(1)
Text("\(r.track.artist) · \(status)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let progress { ProgressView(value: progress) }
}
}
}