import SwiftUI /// The item table: the filter tabs, the rows, and what to do with one. struct ItemListView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback /// The file asked for, or nil for the item's first playable one. var play: (IPX.Entry, IPX.Enclosure?) -> Void var body: some View { VStack(spacing: 0) { header list } .background(Glass.Wash()) .navigationTitle(store.title) #if !targetEnvironment(macCatalyst) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { toolbar } .searchable(text: $store.search, prompt: "Search \(store.title)") } // MARK: - header private var header: some View { VStack(spacing: 8) { Picker("Show", selection: $store.filter) { ForEach(IPX.Filter.allCases, id: \.self) { f in Text(f.rawValue.capitalized).tag(f) } } .pickerStyle(.segmented) .disabled(store.place == .listening) HStack(spacing: 6) { Text(summary).font(.caption).foregroundStyle(Glass.faint) Spacer() if store.loading { ProgressView().controlSize(.mini) } } } .padding(.horizontal, 14) .padding(.vertical, 8) .glassPane(Glass.sticky) } private var summary: String { if let failure = store.failure { return failure } let unread = store.entries.filter { !$0.read }.count return "\(store.total) items, \(unread) unread on this page" } // MARK: - rows private var list: some View { // No selection binding: it swallowed the tap before the NavigationLink could act on // it, so a row highlighted and went nowhere. Navigation is the link's job. List { ForEach(store.entries) { entry in // A destination built here rather than a value matched to one elsewhere: // the value form pushed nothing and gave no reason, and there is nothing to // get wrong about a link that carries its own destination. NavigationLink { ItemDetailView(store: store, playback: playback, entry: entry, play: play) } label: { row(entry) } .onAppear { // Paging by the last row rather than a button: fifty at a time is the // server's page size, and a long list should not need a tap to continue. if entry.id == store.entries.last?.id { store.loadMore() } } } if store.canLoadMore { HStack { Spacer(); ProgressView(); Spacer() } .listRowBackground(Color.clear) } } .listStyle(.plain) .scrollContentBackground(.hidden) .refreshable { await store.refreshFeeds(); store.reload() } .overlay { if store.entries.isEmpty && !store.loading { empty } } } /// Written out rather than ContentUnavailableView, which is iOS 17 and this still runs on 16. private var empty: some View { VStack(spacing: 8) { Image(systemName: store.search.isEmpty ? "tray" : "magnifyingglass") .font(.largeTitle).foregroundStyle(Glass.faint) Text(store.search.isEmpty ? "Nothing here" : "No matches") .font(.headline).foregroundStyle(Glass.dim) Text(store.search.isEmpty ? "Nothing in this view yet." : "Nothing matches \u{201c}\(store.search)\u{201d}.") .font(.footnote).foregroundStyle(Glass.faint) } .multilineTextAlignment(.center) .padding() } private func row(_ entry: IPX.Entry) -> some View { HStack(spacing: 10) { // The unread dot, and the EQ bars in its place for whatever is playing, as the page // marks the current item. Group { 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) } else { Color.clear.frame(width: 8, height: 8) } } .frame(width: 14) VStack(alignment: .leading, spacing: 2) { Text(entry.title ?? "(untitled)") .font(.callout.weight(entry.read ? .regular : .semibold)) .foregroundStyle(entry.read ? Glass.dim : Glass.text) .lineLimit(2) Text(subtitle(entry)).font(.caption2).foregroundStyle(Glass.faint).lineLimit(1) } Spacer(minLength: 6) if entry.flagged { Image(systemName: "pin.fill").font(.caption2).foregroundStyle(Glass.accent) } if entry.playable != nil { Button { play(entry, nil) } label: { Image(systemName: "play.circle") } .buttonStyle(.plain).foregroundStyle(Glass.accent) .accessibilityLabel("Play") } } .padding(.vertical, 3) .listRowBackground(Color.clear) .contentShape(Rectangle()) .swipeActions(edge: .leading) { Button { store.setRead(entry, !entry.read) } label: { Label(entry.read ? "Unread" : "Read", systemImage: entry.read ? "envelope.badge" : "checkmark") } .tint(Glass.accent) } .swipeActions(edge: .trailing) { Button { store.setPinned(entry, !entry.flagged) } label: { Label(entry.flagged ? "Unpin" : "Pin", systemImage: "pin") } .tint(Glass.highlight) } } /// Feed, episode number, and how much is left if it has been started. private func subtitle(_ entry: IPX.Entry) -> String { var bits: [String] = [] if store.place.feedId == nil, let feed = store.feed(entry.feedId) { bits.append(feed.name) } let number = [entry.season.map { "S\($0)" }, entry.episode.map { "E\($0)" }] .compactMap { $0 }.joined() if !number.isEmpty { bits.append(number) } if let d = entry.duration, d > 0 { bits.append(entry.position > 10 ? "\(clock(d - entry.position)) left" : clock(d)) } if let published = entry.published { bits.append(Date(timeIntervalSince1970: TimeInterval(published)) .formatted(date: .abbreviated, time: .omitted)) } return bits.joined(separator: " ยท ") } private func clock(_ seconds: Int) -> String { let (h, m, s) = (seconds / 3600, (seconds % 3600) / 60, seconds % 60) return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) } // MARK: - toolbar @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItemGroup { Button { store.scan() } label: { Image(systemName: "arrow.trianglehead.2.clockwise") } .help("Check for new items") Button { store.readAll() } label: { Image(systemName: "checkmark.circle") } .help("Mark everything here read") Menu { Picker("Sort by", selection: $store.sort) { Text("Published").tag(IPX.Sort.published) Text("Title").tag(IPX.Sort.title) Text("Feed").tag(IPX.Sort.feed) Text("Size").tag(IPX.Sort.size) Text("Type").tag(IPX.Sort.type) } Picker("Order", selection: $store.direction) { Text("Newest first").tag(IPX.Direction.desc) Text("Oldest first").tag(IPX.Direction.asc) } } label: { Image(systemName: "arrow.up.arrow.down") } .help("Sort") } } }