import SwiftUI /// The item table, as the page has it on a wide screen: columns that each sort, rather than the /// stack of rows a phone gets. The page hides these columns under 820px and so does this. struct ItemTableView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback var play: (IPX.Entry, IPX.Enclosure?) -> Void var body: some View { // Every column needs a comparator once the table has a sort order, so the status column // sorts by read rather than being the one column that cannot: a Table with a mix of // sortable and plain columns does not compile. Table(store.entries, selection: $store.selected, sortOrder: $order) { TableColumn("", value: \.read, comparator: BoolComparator()) { entry in state(entry) } .width(22) TableColumn("", value: \.flagged, comparator: BoolComparator()) { entry in Button { store.setPinned(entry, !entry.flagged) } label: { Image(systemName: entry.flagged ? "pin.fill" : "pin") .foregroundStyle(entry.flagged ? Glass.accent : Glass.faint) } .buttonStyle(.plain) .accessibilityLabel(entry.flagged ? "Unpin" : "Pin") } .width(28) TableColumn("Title", value: \.sortTitle) { entry in Text(entry.title ?? "(untitled)") .font(.callout.weight(entry.read ? .regular : .semibold)) .foregroundStyle(entry.read ? Glass.dim : Glass.text) .lineLimit(1) } TableColumn("Feed", value: \.feedId) { entry in Text(store.feed(entry.feedId)?.name ?? entry.feedId) .foregroundStyle(Glass.dim).lineLimit(1) } .width(min: 90, ideal: 150) TableColumn("File", value: \.kind) { entry in file(entry) } .width(60) TableColumn("Size", value: \.size) { entry in Text(entry.sizeText).foregroundStyle(Glass.faint).monospacedDigit() } .width(70) TableColumn("Published", value: \.publishedAt) { entry in Text(entry.dateText).foregroundStyle(Glass.faint).lineLimit(1) } .width(110) } .scrollContentBackground(.hidden) // The table sorts by asking the server, as the list does: the rows on screen are one // page of many, so sorting them here would only order the fifty that arrived. .onChange(of: order) { _ in applyOrder() } .onChange(of: store.entries.count) { _ in } } /// The unread dot, and the waveform in its place for whatever is playing. @ViewBuilder private func state(_ entry: IPX.Entry) -> some View { if playback.nowPlaying?.guid == entry.guid { Image(systemName: "waveform").foregroundStyle(Glass.highlight) } else if !entry.read { Circle().fill(Glass.highlight).frame(width: 8, height: 8) } } @ViewBuilder private func file(_ entry: IPX.Entry) -> some View { HStack(spacing: 4) { if let enclosure = entry.enclosures.first { Image(systemName: entry.symbol) .foregroundStyle(enclosure.isDownloaded ? Glass.good : enclosure.state == "error" ? Glass.bad : Glass.faint) } if entry.playable != nil { Button { play(entry, nil) } label: { Image(systemName: "play.circle") } .buttonStyle(.plain).foregroundStyle(Glass.accent) .accessibilityLabel("Play") } } } // MARK: - sorting /// SwiftUI's Table wants key paths; the server wants column names. This is the join. @State private var order: [KeyPathComparator] = [] private func applyOrder() { guard let first = order.first else { return } let column: IPX.Sort switch first.keyPath { case \IPX.Entry.sortTitle: column = .title case \IPX.Entry.feedId: column = .feed case \IPX.Entry.kind: column = .type case \IPX.Entry.size: column = .size case \IPX.Entry.flagged: column = .kept default: column = .published } store.sort = column store.direction = first.order == .forward ? .asc : .desc } } /// Table needs a comparator for a Bool column; there is no built-in one. struct BoolComparator: SortComparator { var order: SortOrder = .forward func compare(_ a: Bool, _ b: Bool) -> ComparisonResult { guard a != b else { return .orderedSame } let result: ComparisonResult = a ? .orderedAscending : .orderedDescending return order == .forward ? result : (result == .orderedAscending ? .orderedDescending : .orderedAscending) } } extension IPX.Entry { var sortTitle: String { title ?? "" } var publishedAt: Date { Date(timeIntervalSince1970: TimeInterval(published ?? 0)) } var size: Int { enclosures.first?.length ?? 0 } /// What the file is, for the File column, as the page's kindOf does. var kind: String { let mime = (enclosures.first?.mime ?? "").lowercased() if mime.hasPrefix("audio/") { return "audio" } if mime.hasPrefix("video/") { return "video" } if mime.hasPrefix("image/") { return "image" } if mime.contains("pdf") { return "pdf" } if mime.contains("torrent") { return "torrent" } return mime.isEmpty ? "" : "file" } var symbol: String { switch kind { case "audio": return "headphones" case "video": return "film" case "image": return "photo" case "pdf": return "doc.richtext" case "torrent": return "link" default: return "doc" } } var sizeText: String { guard let bytes = enclosures.first?.length, bytes > 0 else { return "" } return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) } var dateText: String { guard published != nil else { return "" } return publishedAt.formatted(date: .abbreviated, time: .omitted) } }