The Pinned tab was not filtering. It sent filter=pinned, and Filter::parse in db.rs knows unread, downloaded, flagged and in_progress and falls through to All for anything else -- so the tab returned every item and looked like it had worked. The column is still named flagged, for what it was before the interface called it pinned, and the page had this right all along. Currently Listening was worse in kind. ipx has filter=in_progress for exactly it: started past the first few seconds, short of the 90% the UI calls finished, measured against the length this person's player reported where there is one. The client asked for everything and trimmed the fifty rows it happened to receive, so the view showed whichever started episodes were near the top of the library, left out the rest, and counted wrong. Both came of writing the filter names from the interface's words instead of reading what the server parses. The tests now assert what each filter means rather than how many rows it returns -- every unread row unread, every downloaded row with a file, every pinned row pinned, every in-progress row started -- because the failure here was a full page of entirely plausible rows, which no count would have caught. Pinned also has to match fewer than everything, which is the shape the bug took. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
8.0 KiB
Swift
201 lines
8.0 KiB
Swift
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.tabs, id: \.self) { f in
|
|
Text(f.label).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")
|
|
}
|
|
}
|
|
}
|