diff --git a/ios/Sources/ContentPaneView.swift b/ios/Sources/ContentPaneView.swift new file mode 100644 index 0000000..fa415d6 --- /dev/null +++ b/ios/Sources/ContentPaneView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +/// The right-hand side on a wide screen: the table across the top, the item you are reading +/// underneath it, and a bar between them to move the split. That is ipx's own arrangement -- +/// you pick a row and read it without losing the list. +struct ContentPaneView: View { + @ObservedObject var store: LibraryStore + @ObservedObject var playback: Playback + var play: (IPX.Entry, IPX.Enclosure?) -> Void + + /// How much of the height the table gets. Kept, because where someone puts the divider is a + /// preference, not a thing to reset every launch. + @AppStorage("ipx.split") private var fraction: Double = 0.45 + @State private var dragging: Double? + + private var split: Double { min(max(dragging ?? fraction, 0.2), 0.85) } + + var body: some View { + GeometryReader { geo in + VStack(spacing: 0) { + header + ItemTableView(store: store, playback: playback, play: play) + .frame(height: max(120, geo.size.height * split)) + grab + reading + } + // The drag is a fraction of the height, so it needs the height it is a fraction of. + .onAppear { paneHeight = geo.size.height } + .onChange(of: geo.size.height) { paneHeight = $0 } + } + .coordinateSpace(name: "panes") + .navigationTitle(store.title) + .toolbar { toolbar } + .searchable(text: $store.search, prompt: "Search \(store.title)") + } + + private var header: some View { + HStack(spacing: 12) { + FeedArt(feed: store.place.feedId.flatMap(store.feed), size: 40) + VStack(alignment: .leading, spacing: 1) { + Text(store.title).font(.headline).foregroundStyle(Glass.text) + Text(summary).font(.caption).foregroundStyle(Glass.faint) + } + Spacer() + Picker("Show", selection: $store.filter) { + ForEach(IPX.Filter.tabs, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + .frame(maxWidth: 360) + .disabled(store.place == .listening) + } + .padding(.horizontal, 14).padding(.vertical, 9) + .glassPane(Glass.sticky) + } + + private var summary: String { + if let failure = store.failure { return failure } + return "\(store.total) items ยท \(store.entries.filter { !$0.read }.count) unread loaded" + } + + /// The divider, which is also the handle. Two pixels of hairline is not something anyone can + /// hit, so the grab area is wider than the line it draws. + private var grab: some View { + ZStack { + Color.clear.frame(height: 10).contentShape(Rectangle()) + Rectangle().fill(Glass.line).frame(height: 1) + } + .gesture( + DragGesture(coordinateSpace: .named("panes")) + .onChanged { value in + dragging = fraction + value.translation.height / max(paneHeight, 1) + } + .onEnded { _ in + if let dragging { fraction = min(max(dragging, 0.2), 0.85) } + dragging = nil + } + ) + #if targetEnvironment(macCatalyst) + .onHover { inside in + // The pointer should say the thing can be moved before anyone tries it. + if inside { NSCursorShim.resizeUpDown() } else { NSCursorShim.arrow() } + } + #endif + } + + @State private var paneHeight: Double = 600 + + @ViewBuilder private var reading: some View { + if let entry = store.selectedEntry { + ItemDetailView(store: store, playback: playback, entry: entry, play: play) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack { + Spacer() + Text("Pick an item to read it.").font(.callout).foregroundStyle(Glass.faint) + Spacer() + } + .frame(maxWidth: .infinity) + } + } + + @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") + } + } +} + +#if targetEnvironment(macCatalyst) +/// AppKit's cursors are not on Catalyst's side of the wall, so this asks for them by name. It +/// is cosmetic: without it the divider still drags, the pointer just never says so. +enum NSCursorShim { + private static func cursor(_ selector: String) -> NSObject? { + guard let klass = NSClassFromString("NSCursor") as? NSObject.Type else { return nil } + return klass.perform(Selector(selector))?.takeUnretainedValue() as? NSObject + } + static func resizeUpDown() { cursor("resizeUpDownCursor")?.perform(Selector("set")) } + static func arrow() { cursor("arrowCursor")?.perform(Selector("set")) } +} +#endif diff --git a/ios/Sources/FeedListView.swift b/ios/Sources/FeedListView.swift index f27825c..c3be848 100644 --- a/ios/Sources/FeedListView.swift +++ b/ios/Sources/FeedListView.swift @@ -39,6 +39,7 @@ struct FeedListView: View { } .listStyle(.sidebar) .scrollContentBackground(.hidden) + .scrollContentBackground(.hidden) .searchable(text: $filter, placement: .sidebar, prompt: "Filter feeds") .navigationTitle("iPodderX") } diff --git a/ios/Sources/Glass.swift b/ios/Sources/Glass.swift index acec996..badc75b 100644 --- a/ios/Sources/Glass.swift +++ b/ios/Sources/Glass.swift @@ -56,34 +56,38 @@ enum Glass { /// The three radial gradients ipx lays under everything, so the frosting has colour to pick /// up. Without it a material over a flat background is just grey. + /// + /// Each gradient fills the whole view and is placed by its centre, rather than being sized + /// into a box and then moved: a box has edges, and they showed as straight seams down the + /// window. They composite normally, as the CSS does. Blending them plusLighter added the + /// three together into something far louder than the theme -- it is a wash, not a light show. struct Wash: View { @Environment(\.colorScheme) private var scheme private var base: Color { scheme == .dark ? Color(hex: 0x0b0d14) : Color(hex: 0xeef1f7) } + /// The stylesheet's alphas are for colour seen through a frosted panel. Most of this is + /// seen in the open, where the same values are too much. + private var strength: Double { scheme == .dark ? 0.5 : 0.3 } var body: some View { GeometryReader { geo in - let w = geo.size.width, h = geo.size.height + let reach = max(geo.size.width, geo.size.height) ZStack { base - // Positions and sizes as the stylesheet has them: 8% 0%, 92% 18%, 60% 100%. - blob(Color(hex: 0x4078ff).opacity(scheme == .dark ? 0.30 : 0.18), - at: CGPoint(x: w * 0.08, y: 0), size: CGSize(width: w * 1.2, height: h * 1.1)) - blob(Color(hex: 0xaf52de).opacity(scheme == .dark ? 0.24 : 0.14), - at: CGPoint(x: w * 0.92, y: h * 0.18), size: CGSize(width: w, height: h)) - blob(Color(hex: 0x30b0c7).opacity(scheme == .dark ? 0.20 : 0.12), - at: CGPoint(x: w * 0.60, y: h), size: CGSize(width: w * 1.2, height: h * 1.2)) + blob(0x4078ff, 0.30, at: UnitPoint(x: 0.08, y: 0.00), reach * 0.75) + blob(0xaf52de, 0.24, at: UnitPoint(x: 0.92, y: 0.18), reach * 0.65) + blob(0x30b0c7, 0.20, at: UnitPoint(x: 0.60, y: 1.00), reach * 0.75) } - .ignoresSafeArea() } + .ignoresSafeArea() } - private func blob(_ colour: Color, at centre: CGPoint, size: CGSize) -> some View { - RadialGradient(colors: [colour, colour.opacity(0)], - center: .center, startRadius: 0, endRadius: max(size.width, size.height) / 2) - .frame(width: size.width, height: size.height) - .position(centre) - .blendMode(.plusLighter) + private func blob(_ hex: UInt32, _ alpha: Double, + at centre: UnitPoint, _ radius: Double) -> some View { + RadialGradient( + gradient: Gradient(colors: [Color(hex: hex).opacity(alpha * strength), + Color(hex: hex).opacity(0)]), + center: centre, startRadius: 0, endRadius: radius) } } diff --git a/ios/Sources/ItemDetailView.swift b/ios/Sources/ItemDetailView.swift index d750e8f..797e559 100644 --- a/ios/Sources/ItemDetailView.swift +++ b/ios/Sources/ItemDetailView.swift @@ -24,7 +24,6 @@ struct ItemDetailView: View { } .padding(16) } - .background(Glass.Wash()) .navigationTitle(feed?.name ?? entry.feedId) #if !targetEnvironment(macCatalyst) .navigationBarTitleDisplayMode(.inline) diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift index 87ce151..f264621 100644 --- a/ios/Sources/ItemListView.swift +++ b/ios/Sources/ItemListView.swift @@ -12,7 +12,6 @@ struct ItemListView: View { header list } - .background(Glass.Wash()) .navigationTitle(store.title) #if !targetEnvironment(macCatalyst) .navigationBarTitleDisplayMode(.inline) diff --git a/ios/Sources/ItemTableView.swift b/ios/Sources/ItemTableView.swift new file mode 100644 index 0000000..85b4898 --- /dev/null +++ b/ios/Sources/ItemTableView.swift @@ -0,0 +1,146 @@ +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) + } +}