A native feed list and item list (#3)
The lists are native now, and the page is a button in the toolbar: it is still where signing in happens, and it is still the whole of settings, the admin page, the directory and OPML, which were never going to be rewritten. LibraryStore holds a page of items and asks for the next, because the sorting, filtering and searching are the server's work and ten thousand items have no business being in memory to be sorted here. Read and pinned are set locally and sent after, as the page's readWrites map does and for the same reason: a list asked for before the write lands answers with the old state, which put the dot back on an item just read. The interface follows the account's light or dark rather than the phone's, and defaults to dark when nobody has chosen, because that is what ipx's own theme.ts does. Following the system instead put a light list in front of a dark page. preferredColorScheme was not enough on its own -- inside a hosting controller it did not reach the hierarchy -- so the style is overridden on the controller, which also carries to the page presented over it. The sidebar had to be broken into sub-views: the whole list in one expression was more than the type checker would work through, and it said so rather than compiling it. Tested against the real library, 135 feeds and eleven thousand items, and the playback test now drives the native row rather than the page's button. Thirteen pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,18 @@ extension IPX {
|
||||
|
||||
/// Unique across feeds; guid alone is not.
|
||||
var id: String { feedId + "\n" + guid }
|
||||
|
||||
/// Memberwise, so a row can be rebuilt with one flag moved. Declaring it keeps the
|
||||
/// synthesised one, which a decoded type does not otherwise expose.
|
||||
init(guid: String, feedId: String, title: String?, link: String?, published: Int?,
|
||||
description: String?, read: Bool, flagged: Bool, image: String?, duration: Int?,
|
||||
episode: Int?, season: Int?, position: Int, enclosures: [Enclosure]) {
|
||||
self.guid = guid; self.feedId = feedId; self.title = title; self.link = link
|
||||
self.published = published; self.description = description; self.read = read
|
||||
self.flagged = flagged; self.image = image; self.duration = duration
|
||||
self.episode = episode; self.season = season; self.position = position
|
||||
self.enclosures = enclosures
|
||||
}
|
||||
var playable: Enclosure? { enclosures.first(where: \.isPlayable) }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
||||
164
ios/Sources/FeedListView.swift
Normal file
164
ios/Sources/FeedListView.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The sidebar: the places first, then the feeds, with OPML subscriptions as folders.
|
||||
struct FeedListView: View {
|
||||
@ObservedObject var store: LibraryStore
|
||||
@State private var filter = ""
|
||||
@State private var expanded: Set<String> = []
|
||||
|
||||
/// Feeds that are not inside an OPML folder, pinned ones first, then by name.
|
||||
private var loose: [IPX.Feed] {
|
||||
store.feeds
|
||||
.filter { $0.group == nil && !isFolder($0) }
|
||||
.filter(matches)
|
||||
.sorted { ($0.pinned ? 0 : 1, $0.name.lowercased()) < ($1.pinned ? 0 : 1, $1.name.lowercased()) }
|
||||
}
|
||||
|
||||
/// An OPML subscription: other feeds name it as their group.
|
||||
private func isFolder(_ feed: IPX.Feed) -> Bool {
|
||||
store.feeds.contains { $0.group == feed.id }
|
||||
}
|
||||
|
||||
private var folders: [IPX.Feed] {
|
||||
store.feeds.filter(isFolder).sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
private func children(_ folder: IPX.Feed) -> [IPX.Feed] {
|
||||
store.feeds.filter { $0.group == folder.id }.filter(matches)
|
||||
.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
private func matches(_ feed: IPX.Feed) -> Bool {
|
||||
filter.isEmpty || feed.name.localizedCaseInsensitiveContains(filter)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(selection: selection) {
|
||||
placesSection
|
||||
feedsSection
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.scrollContentBackground(.hidden)
|
||||
.searchable(text: $filter, placement: .sidebar, prompt: "Filter feeds")
|
||||
.navigationTitle("iPodderX")
|
||||
}
|
||||
|
||||
private var selection: Binding<LibraryStore.Place?> {
|
||||
Binding(get: { store.place }, set: { store.place = $0 ?? .all })
|
||||
}
|
||||
|
||||
// Split out because the whole list in one expression is more than the type checker will
|
||||
// work through: it gave up rather than compiling it.
|
||||
@ViewBuilder private var placesSection: some View {
|
||||
Section {
|
||||
let unread = store.feeds.reduce(0) { $0 + $1.unread }
|
||||
place(.all, "All Subscriptions", "square.3.layers.3d", count: unread)
|
||||
place(.listening, "Currently Listening", "headphones", count: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var feedsSection: some View {
|
||||
Section {
|
||||
ForEach(loose) { feed in row(feed) }
|
||||
ForEach(folders) { folder in folderRow(folder) }
|
||||
}
|
||||
}
|
||||
|
||||
private func folderRow(_ folder: IPX.Feed) -> some View {
|
||||
DisclosureGroup(isExpanded: expansion(folder.id)) {
|
||||
ForEach(children(folder)) { row($0) }
|
||||
} label: {
|
||||
Label(folder.name, systemImage: "folder").foregroundStyle(Glass.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func expansion(_ id: String) -> Binding<Bool> {
|
||||
Binding(get: { expanded.contains(id) },
|
||||
set: { open in
|
||||
if open { expanded.insert(id) } else { expanded.remove(id) }
|
||||
})
|
||||
}
|
||||
|
||||
private func place(_ p: LibraryStore.Place, _ title: String, _ symbol: String, count: Int?) -> some View {
|
||||
Label(title, systemImage: symbol)
|
||||
.badge(count.map { $0 > 0 ? "\($0)" : "" } ?? "")
|
||||
.tag(p)
|
||||
}
|
||||
|
||||
private func row(_ feed: IPX.Feed) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
FeedArt(feed: feed, size: 30)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
if feed.pinned {
|
||||
Image(systemName: "pin.fill").font(.system(size: 8)).foregroundStyle(Glass.accent)
|
||||
}
|
||||
Text(feed.name).lineLimit(1).foregroundStyle(Glass.text)
|
||||
}
|
||||
Text("\(feed.entries) items · \(feed.downloaded) downloaded")
|
||||
.font(.caption2).foregroundStyle(Glass.faint).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if feed.lastError != nil {
|
||||
// Something is wrong with the feed itself, which the counts cannot show.
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.caption2).foregroundStyle(Glass.bad)
|
||||
}
|
||||
if feed.unread > 0 {
|
||||
Text("\(feed.unread)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Glass.accent.opacity(0.22), in: Capsule())
|
||||
.foregroundStyle(Glass.accent)
|
||||
}
|
||||
}
|
||||
.tag(LibraryStore.Place.feed(feed.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// A feed's picture, or its initials. Two letters read as a thing on purpose; an empty grey
|
||||
/// square reads as something that failed to load.
|
||||
struct FeedArt: View {
|
||||
let feed: IPX.Feed?
|
||||
var size: CGFloat = 30
|
||||
@State private var image: UIImage?
|
||||
@Environment(\.api) private var api
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
ZStack {
|
||||
Rectangle().fill(Glass.accent.opacity(0.18))
|
||||
Text(initials)
|
||||
.font(.system(size: size * 0.38, weight: .semibold))
|
||||
.foregroundStyle(Glass.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))
|
||||
.task(id: feed?.image) {
|
||||
guard let path = feed?.image, !path.isEmpty else { return }
|
||||
image = await api.image(path)
|
||||
}
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let words = (feed?.name ?? "").split(separator: " ").prefix(2)
|
||||
let letters = words.compactMap(\.first).map(String.init).joined()
|
||||
return letters.isEmpty ? "?" : letters.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
private struct APIKey: EnvironmentKey {
|
||||
static let defaultValue = API()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var api: API {
|
||||
get { self[APIKey.self] }
|
||||
set { self[APIKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
191
ios/Sources/ItemListView.swift
Normal file
191
ios/Sources/ItemListView.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
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
|
||||
var play: (IPX.Entry) -> 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 {
|
||||
List(selection: $store.selected) {
|
||||
ForEach(store.entries) { entry in
|
||||
row(entry)
|
||||
.tag(entry.id)
|
||||
.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) } 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
179
ios/Sources/LibraryStore.swift
Normal file
179
ios/Sources/LibraryStore.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// What the lists are showing, and how it changes.
|
||||
///
|
||||
/// The sorting, filtering and searching are the server's work: `GET /api/entries` takes them all
|
||||
/// and pages fifty at a time, so this holds a page and asks for the next rather than keeping ten
|
||||
/// thousand items in memory and sorting them here.
|
||||
@MainActor
|
||||
final class LibraryStore: ObservableObject {
|
||||
/// Where the list is pointed. The places come before any feed, as they do in the page.
|
||||
enum Place: Hashable {
|
||||
case all
|
||||
case listening
|
||||
case feed(String)
|
||||
|
||||
var feedId: String? { if case .feed(let id) = self { return id } else { return nil } }
|
||||
}
|
||||
|
||||
@Published private(set) var feeds: [IPX.Feed] = []
|
||||
@Published private(set) var entries: [IPX.Entry] = []
|
||||
@Published private(set) var total = 0
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var failure: String?
|
||||
/// Light or dark as the account has it, nil for auto. ipx keeps the theme on the user so it
|
||||
/// follows the person rather than the browser; the app should follow the same choice rather
|
||||
/// than the phone's, or it disagrees with the page it sits in front of.
|
||||
@Published private(set) var appearance: ColorScheme?
|
||||
|
||||
@Published var place: Place = .all { didSet { if place != oldValue { reload() } } }
|
||||
@Published var filter: IPX.Filter = .all { didSet { if filter != oldValue { reload() } } }
|
||||
@Published var sort: IPX.Sort = .published { didSet { reload() } }
|
||||
@Published var direction: IPX.Direction = .desc { didSet { reload() } }
|
||||
@Published var search = "" { didSet { searchChanged() } }
|
||||
@Published var selected: IPX.Entry.ID?
|
||||
|
||||
private let api: API
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
init(api: API) { self.api = api }
|
||||
|
||||
var canLoadMore: Bool { entries.count < total }
|
||||
|
||||
var selectedEntry: IPX.Entry? { entries.first { $0.id == selected } }
|
||||
|
||||
/// The feed a row belongs to, for its name and art. All Subscriptions mixes them, so a row
|
||||
/// cannot assume the selected feed is its own.
|
||||
func feed(_ id: String) -> IPX.Feed? { feeds.first { $0.id == id } }
|
||||
|
||||
var title: String {
|
||||
switch place {
|
||||
case .all: return "All Subscriptions"
|
||||
case .listening: return "Currently Listening"
|
||||
case .feed(let id): return feed(id)?.name ?? id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - loading
|
||||
|
||||
func refreshFeeds() async {
|
||||
appearance = .dark
|
||||
if let me = try? await api.me() {
|
||||
switch me.mode {
|
||||
case "light": appearance = .light
|
||||
case "auto": appearance = nil // follow the system, as the page does for Auto
|
||||
// Dark when nobody has chosen, because that is ipx's own default -- theme.ts
|
||||
// starts at modern/dark rather than at the system. Following the phone here would
|
||||
// put a light list in front of a dark page.
|
||||
default: appearance = .dark
|
||||
}
|
||||
}
|
||||
do {
|
||||
feeds = try await api.feeds()
|
||||
failure = nil
|
||||
} catch {
|
||||
failure = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
}
|
||||
|
||||
func reload() {
|
||||
loadTask?.cancel()
|
||||
loadTask = Task { await load(offset: 0) }
|
||||
}
|
||||
|
||||
func loadMore() {
|
||||
guard !loading, canLoadMore else { return }
|
||||
loadTask = Task { await load(offset: entries.count) }
|
||||
}
|
||||
|
||||
private func load(offset: Int) async {
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
do {
|
||||
// Currently Listening is not a feed; it is the started-but-unfinished filter, which
|
||||
// the page reaches through the same route.
|
||||
let page = try await api.entries(
|
||||
feed: place.feedId,
|
||||
filter: place == .listening ? .all : filter,
|
||||
search: search,
|
||||
sort: sort,
|
||||
direction: direction,
|
||||
offset: offset)
|
||||
guard !Task.isCancelled else { return }
|
||||
var rows = offset == 0 ? page.entries : entries + page.entries
|
||||
if place == .listening {
|
||||
rows = rows.filter { $0.position > 0 && !$0.read }
|
||||
}
|
||||
entries = rows
|
||||
total = place == .listening ? rows.count : page.total
|
||||
failure = nil
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
failure = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Typing should not fire a request per keystroke, nor wait so long it feels broken.
|
||||
private func searchChanged() {
|
||||
searchTask?.cancel()
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - writing
|
||||
|
||||
/// Read and pinned are set here first and sent after. The page does the same, for the same
|
||||
/// reason: a list asked for before the write lands answers with the old state, which put the
|
||||
/// dot back on an item just read.
|
||||
func setRead(_ entry: IPX.Entry, _ read: Bool) {
|
||||
replace(entry.id) { $0.with(read: read) }
|
||||
Task {
|
||||
do { try await api.setRead(read, feed: entry.feedId, guid: entry.guid) }
|
||||
catch { replace(entry.id) { $0.with(read: !read) } }
|
||||
}
|
||||
}
|
||||
|
||||
func setPinned(_ entry: IPX.Entry, _ pinned: Bool) {
|
||||
replace(entry.id) { $0.with(flagged: pinned) }
|
||||
Task {
|
||||
do { try await api.setPinned(pinned, feed: entry.feedId, guid: entry.guid) }
|
||||
catch { replace(entry.id) { $0.with(flagged: !pinned) } }
|
||||
}
|
||||
}
|
||||
|
||||
func readAll() {
|
||||
let ids = entries.map(\.id)
|
||||
for id in ids { replace(id) { $0.with(read: true) } }
|
||||
Task {
|
||||
try? await api.readAll(feed: place.feedId)
|
||||
await refreshFeeds()
|
||||
}
|
||||
}
|
||||
|
||||
func scan() {
|
||||
Task {
|
||||
try? await api.fetch(feed: place.feedId)
|
||||
}
|
||||
}
|
||||
|
||||
private func replace(_ id: IPX.Entry.ID, _ change: (IPX.Entry) -> IPX.Entry) {
|
||||
guard let at = entries.firstIndex(where: { $0.id == id }) else { return }
|
||||
entries[at] = change(entries[at])
|
||||
}
|
||||
}
|
||||
|
||||
extension IPX.Entry {
|
||||
/// A copy with one flag moved. The type is decoded from the server and has no setters, which
|
||||
/// is deliberate: the only things that change locally are the two this person owns.
|
||||
func with(read: Bool? = nil, flagged: Bool? = nil) -> IPX.Entry {
|
||||
IPX.Entry(guid: guid, feedId: feedId, title: title, link: link, published: published,
|
||||
description: description, read: read ?? self.read,
|
||||
flagged: flagged ?? self.flagged, image: image, duration: duration,
|
||||
episode: episode, season: season, position: position, enclosures: enclosures)
|
||||
}
|
||||
}
|
||||
36
ios/Sources/LibraryView.swift
Normal file
36
ios/Sources/LibraryView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The native interface: feeds beside items, with the player bar under both.
|
||||
///
|
||||
/// What is not here yet has somewhere to go rather than being missing. The item pane is #4 and
|
||||
/// the management screens are #5; until then the toolbar's Page button opens ipx's own page,
|
||||
/// which is still the whole app in a web view and can do everything this cannot.
|
||||
struct LibraryView: View {
|
||||
@ObservedObject var store: LibraryStore
|
||||
@ObservedObject var playback: Playback
|
||||
var play: (IPX.Entry) -> Void
|
||||
var openPage: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
FeedListView(store: store)
|
||||
.background(Glass.Wash())
|
||||
} detail: {
|
||||
ItemListView(store: store, playback: playback, play: play)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: openPage) { Image(systemName: "safari") }
|
||||
.help("Open the full page: settings, the directory, OPML")
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(Glass.accent)
|
||||
.task {
|
||||
await store.refreshFeeds()
|
||||
store.reload()
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
PlayerBarView(playback: playback)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
ios/Sources/RootViewController.swift
Normal file
95
ios/Sources/RootViewController.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// What the window holds: the native interface, with ipx's page a button away.
|
||||
///
|
||||
/// The page is not a fallback here, it is the rest of the app. Settings, the admin page, the
|
||||
/// directory and OPML have never been native and are not meant to be (#5), and an item's show
|
||||
/// notes are still HTML (#4). Both live behind the same web view that used to be the whole
|
||||
/// interface, so nothing has been lost by putting a list in front of it.
|
||||
final class RootViewController: UIViewController {
|
||||
private let api = API()
|
||||
private var store: LibraryStore!
|
||||
private var cookies: CookieBridge!
|
||||
private var playback: Playback!
|
||||
private var page: WebViewController!
|
||||
private var host: UIHostingController<AnyView>!
|
||||
private var watching: AnyCancellable?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
|
||||
// The page is built first and kept: it owns the web view whose cookies authenticate
|
||||
// everything, including the API client and the player. Signing in happens there.
|
||||
page = WebViewController()
|
||||
page.loadViewIfNeeded()
|
||||
cookies = page.cookieBridge
|
||||
playback = page.player
|
||||
|
||||
store = LibraryStore(api: api)
|
||||
|
||||
let root = LibraryView(
|
||||
store: store,
|
||||
playback: playback,
|
||||
play: { [weak self] entry in self?.play(entry) },
|
||||
openPage: { [weak self] in self?.showPage() })
|
||||
.environment(\.api, api)
|
||||
|
||||
host = UIHostingController(rootView: AnyView(root))
|
||||
addChild(host)
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(host.view)
|
||||
NSLayoutConstraint.activate([
|
||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
|
||||
// preferredColorScheme inside a hosting controller does not reliably reach the whole
|
||||
// hierarchy -- the list stayed light against a dark account. Overriding the style on the
|
||||
// controller does, and it carries to the page presented over it as well.
|
||||
watching = store.$appearance.sink { [weak self] scheme in
|
||||
let style: UIUserInterfaceStyle
|
||||
switch scheme {
|
||||
case .some(.dark): style = .dark
|
||||
case .some(.light): style = .light
|
||||
default: style = .unspecified
|
||||
}
|
||||
self?.overrideUserInterfaceStyle = style
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a row through the same Playback the lock screen and the car drive.
|
||||
private func play(_ entry: IPX.Entry) {
|
||||
guard let file = entry.playable, let url = ServerSettings.url("/media/\(file.id)") else { return }
|
||||
let feed = store.feed(entry.feedId)
|
||||
playback.load(.init(
|
||||
url: url,
|
||||
feedId: entry.feedId,
|
||||
guid: entry.guid,
|
||||
title: entry.title ?? "",
|
||||
feedTitle: feed?.name ?? entry.feedId,
|
||||
artwork: entry.image ?? feed?.image,
|
||||
position: entry.position,
|
||||
duration: entry.duration))
|
||||
// Where it starts is the entry's own position, since there is no page here to decide it.
|
||||
if entry.position > 5 { playback.seek(to: Double(entry.position)) }
|
||||
playback.play()
|
||||
}
|
||||
|
||||
private func showPage() {
|
||||
guard page.presentingViewController == nil else { return }
|
||||
let nav = UINavigationController(rootViewController: page)
|
||||
page.navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
systemItem: .done, primaryAction: UIAction { [weak self] _ in
|
||||
self?.dismiss(animated: true)
|
||||
// The page may have changed read state or subscriptions while it was open.
|
||||
Task { await self?.store.refreshFeeds(); self?.store.reload() }
|
||||
})
|
||||
present(nav, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
let w = UIWindow(windowScene: windowScene)
|
||||
w.rootViewController = WebViewController()
|
||||
w.rootViewController = RootViewController()
|
||||
w.makeKeyAndVisible()
|
||||
window = w
|
||||
}
|
||||
|
||||
@@ -11,9 +11,14 @@ final class WebViewController: UIViewController, WKNavigationDelegate {
|
||||
private var cookies: CookieBridge!
|
||||
private var api: API!
|
||||
private var playback: Playback!
|
||||
|
||||
/// The web view's cookie store is what authenticates everything else, and its Playback is
|
||||
/// the one the lock screen drives, so the native interface borrows both rather than making
|
||||
/// a second of either.
|
||||
var cookieBridge: CookieBridge { cookies }
|
||||
var player: Playback { playback }
|
||||
private var bridge: Bridge!
|
||||
|
||||
private var playerBar: UIHostingController<PlayerBarView>!
|
||||
private let banner = UIView()
|
||||
private let bannerText = UILabel()
|
||||
private var checkedBridge = false
|
||||
@@ -46,6 +51,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate {
|
||||
pageAtTop,
|
||||
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
])
|
||||
|
||||
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore)
|
||||
@@ -60,32 +66,10 @@ final class WebViewController: UIViewController, WKNavigationDelegate {
|
||||
Zoom.install(into: cfg.userContentController)
|
||||
|
||||
setUpBanner()
|
||||
setUpPlayerBar()
|
||||
|
||||
if ServerSettings.isConfigured { load() }
|
||||
}
|
||||
|
||||
// MARK: - the player bar
|
||||
|
||||
/// Ours, below the page, with the page's own bar hidden. Two bars for one player would be
|
||||
/// two sets of buttons disagreeing about what is playing, and only one of them is the thing
|
||||
/// CarPlay and the lock screen are driving.
|
||||
private func setUpPlayerBar() {
|
||||
playerBar = UIHostingController(rootView: PlayerBarView(playback: playback))
|
||||
playerBar.view.backgroundColor = .clear
|
||||
playerBar.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addChild(playerBar)
|
||||
view.addSubview(playerBar.view)
|
||||
playerBar.didMove(toParent: self)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
playerBar.view.topAnchor.constraint(equalTo: webView.bottomAnchor),
|
||||
playerBar.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
playerBar.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
playerBar.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - how big the page is drawn
|
||||
|
||||
/// The page is sized for a phone and for a browser window, and on a Mac -- where the window
|
||||
|
||||
Reference in New Issue
Block a user